cctally 1.113.0 → 1.115.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 (97) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +4 -6
  3. package/bin/_cctally_account.py +32 -8
  4. package/bin/_cctally_cache.py +6035 -722
  5. package/bin/_cctally_completion.py +1287 -0
  6. package/bin/_cctally_config.py +37 -0
  7. package/bin/_cctally_core.py +400 -2
  8. package/bin/_cctally_dashboard.py +98 -20
  9. package/bin/_cctally_dashboard_conversation.py +292 -23
  10. package/bin/_cctally_dashboard_envelope.py +16 -3
  11. package/bin/_cctally_dashboard_perf.py +128 -9
  12. package/bin/_cctally_dashboard_sources.py +285 -213
  13. package/bin/_cctally_db.py +1177 -23
  14. package/bin/_cctally_diagnosis_sources.py +22 -4
  15. package/bin/_cctally_doctor.py +572 -45
  16. package/bin/_cctally_five_hour.py +84 -33
  17. package/bin/_cctally_forecast.py +108 -15
  18. package/bin/_cctally_journal.py +3950 -116
  19. package/bin/_cctally_journal_repair.py +101 -12
  20. package/bin/_cctally_milestone_history.py +82 -9
  21. package/bin/_cctally_parser.py +42 -0
  22. package/bin/_cctally_percent_breakdown.py +3 -2
  23. package/bin/_cctally_project.py +8 -2
  24. package/bin/_cctally_quarantine.py +652 -0
  25. package/bin/_cctally_quota.py +227 -37
  26. package/bin/_cctally_quota_model.py +360 -83
  27. package/bin/_cctally_record.py +691 -149
  28. package/bin/_cctally_rederive.py +320 -41
  29. package/bin/_cctally_refresh.py +84 -13
  30. package/bin/_cctally_source_analytics.py +83 -17
  31. package/bin/_cctally_statusline.py +6154 -517
  32. package/bin/_cctally_store.py +367 -53
  33. package/bin/_cctally_transcript.py +8 -7
  34. package/bin/_cctally_tui.py +61 -56
  35. package/bin/_cctally_weekrefs.py +128 -21
  36. package/bin/_lib_account_binding.py +152 -0
  37. package/bin/_lib_account_protocol.py +386 -0
  38. package/bin/_lib_aggregators.py +4 -0
  39. package/bin/_lib_cache_coverage.py +13 -1
  40. package/bin/_lib_cache_report.py +35 -6
  41. package/bin/_lib_cache_writer_lock.py +19 -3
  42. package/bin/_lib_clone_copy.py +142 -0
  43. package/bin/_lib_codex_conversation.py +32 -0
  44. package/bin/_lib_codex_conversation_query.py +297 -49
  45. package/bin/_lib_codex_conversation_watch.py +59 -14
  46. package/bin/_lib_codex_drain_admission.py +214 -0
  47. package/bin/_lib_codex_metadata.py +18 -1
  48. package/bin/_lib_codex_publication_barrier.py +277 -0
  49. package/bin/_lib_codex_rollout_container.py +502 -0
  50. package/bin/_lib_conversation_dispatch.py +3 -0
  51. package/bin/_lib_conversation_query.py +106 -59
  52. package/bin/_lib_conversation_retention.py +1605 -640
  53. package/bin/_lib_cost_provenance.py +53 -2
  54. package/bin/_lib_detector_access_registry.py +3817 -0
  55. package/bin/_lib_doctor.py +714 -69
  56. package/bin/_lib_five_hour.py +126 -0
  57. package/bin/_lib_fmt.py +11 -0
  58. package/bin/_lib_ingest_frontier.py +414 -20
  59. package/bin/_lib_journal.py +141 -10
  60. package/bin/_lib_jsonl.py +5 -4
  61. package/bin/_lib_legacy_spool.py +126 -0
  62. package/bin/_lib_legacy_writer_evidence.py +136 -0
  63. package/bin/_lib_merge_gate.py +231 -86
  64. package/bin/_lib_meter_rate_change.py +10 -0
  65. package/bin/_lib_outcome_quarantine.py +690 -0
  66. package/bin/_lib_perf.py +27 -1
  67. package/bin/_lib_pricing.py +199 -9
  68. package/bin/_lib_pricing_check.py +20 -4
  69. package/bin/_lib_quarantine.py +154 -0
  70. package/bin/_lib_quota_calibration.py +53 -8
  71. package/bin/_lib_quota_copy.py +2 -1
  72. package/bin/_lib_quota_model.py +288 -46
  73. package/bin/_lib_reclaim_planner.py +792 -0
  74. package/bin/_lib_rederive.py +319 -82
  75. package/bin/_lib_segment_summary.py +35 -2
  76. package/bin/_lib_snapshot_cache.py +21 -0
  77. package/bin/_lib_sqlite_close.py +81 -0
  78. package/bin/_lib_statusline_candidates.py +292 -23
  79. package/bin/_lib_subscription_weeks.py +100 -17
  80. package/bin/_lib_tick_stats.py +217 -17
  81. package/bin/_lib_wal_checkpoint.py +1077 -0
  82. package/bin/_lib_write_budget.py +483 -0
  83. package/bin/_lib_write_io.py +703 -0
  84. package/bin/cctally +31 -1
  85. package/dashboard/static/assets/{ConversationsView-CEZ-BUyi.js → ConversationsView-Bc8LCOAZ.js} +1 -1
  86. package/dashboard/static/assets/{DoctorModal-CE1oH_Zy.js → DoctorModal-D--qVBwe.js} +1 -1
  87. package/dashboard/static/assets/ModalRoot-DLPgyAM_.js +1 -0
  88. package/dashboard/static/assets/{ProjectsDrillPanel-B3DZoCeD.js → ProjectsDrillPanel-z53a4v4M.js} +1 -1
  89. package/dashboard/static/assets/{SourceDetailModal-CHp9EgLP.js → SourceDetailModal-Bw0T8W-W.js} +1 -1
  90. package/dashboard/static/assets/{UpdateModal-5iKST6jo.js → UpdateModal-D5AKx4Yw.js} +1 -1
  91. package/dashboard/static/assets/index-BKITk1CT.css +1 -0
  92. package/dashboard/static/assets/{index-Cm0VTxJC.js → index-BXDOzSng.js} +3 -3
  93. package/dashboard/static/assets/{outlineNavigation-DkXGL8J6.js → outlineNavigation-JObY7cUY.js} +1 -1
  94. package/dashboard/static/dashboard.html +2 -2
  95. package/package.json +19 -1
  96. package/dashboard/static/assets/ModalRoot-D5Y8IFUG.js +0 -1
  97. package/dashboard/static/assets/index-Dt4tqf43.css +0 -1
@@ -31,13 +31,14 @@ from __future__ import annotations
31
31
  import json
32
32
  import fcntl
33
33
  import os
34
+ import _lib_codex_rollout_container as _rc
34
35
  import secrets
35
36
  import sqlite3
36
37
  import threading
37
38
  import pathlib
38
39
  import shlex
39
40
  import time
40
- from dataclasses import dataclass
41
+ from dataclasses import dataclass, replace
41
42
  from typing import NamedTuple
42
43
  from _lib_retained_size import retained_size_bytes
43
44
 
@@ -73,6 +74,160 @@ _COMPLETE_KEYS = {
73
74
  "claude": "claude_ingest_walk_complete",
74
75
  "codex": CODEX_FULL_WALK_COMPLETE_KEY,
75
76
  }
77
+ #: Review N3. The durable debt-only tokens: cache.db for the accounting
78
+ #: frontier, conversations.db for the conversation frontier. A full walk
79
+ #: clean except for pending prune debt writes a fresh one; any other
80
+ #: unbudgeted or certifying full walk deletes it.
81
+ CODEX_FULL_WALK_DEBT_ONLY_KEY = "dashboard_codex_full_walk_debt_only"
82
+ CODEX_CONVERSATION_WALK_DEBT_ONLY_KEY = "dashboard_codex_conversation_walk_debt_only"
83
+ #: How long a frontier holds a debt-only store before walking it again: the
84
+ #: certificate's own staleness bound, so a store whose certificate is
85
+ #: withheld (spec 5.7) is never staler than a certified one.
86
+ FRONTIER_DEBT_HOLD_SECONDS = FRONTIER_CERTIFICATE_MAX_AGE_SECONDS
87
+ _DEBT_HOLD = {
88
+ "accounting": (CODEX_FULL_WALK_DEBT_ONLY_KEY, "codex_prune_debt"),
89
+ "conversation": (CODEX_CONVERSATION_WALK_DEBT_ONLY_KEY,
90
+ "codex_conversation_prune_debt"),
91
+ }
92
+
93
+
94
+ class _DebtCleared:
95
+ """``stats`` as if no prune debt were pending (review N3)."""
96
+
97
+ def __init__(self, stats):
98
+ self._stats = stats
99
+
100
+ def __getattr__(self, name):
101
+ if name == "prune_debt_pending":
102
+ return 0
103
+ if name == "full_walk_complete":
104
+ return bool(getattr(self._stats, "full_walk_debt_only", False)
105
+ or getattr(self._stats, "full_walk_complete", False))
106
+ return getattr(self._stats, name)
107
+
108
+
109
+ def debt_only_refusal(predicate, mode: str, stats, **kwargs) -> bool:
110
+ """True when pending prune debt is the ONLY reason ``predicate`` refuses
111
+ this result: the walk observed everything else clean (review N3). The
112
+ frontier's own predicate decides, so the walks and the planner can never
113
+ disagree about what "clean" means."""
114
+ if stats is None or not getattr(stats, "prune_debt_pending", 0):
115
+ return False
116
+ return (not predicate(mode, stats, **kwargs)
117
+ and bool(predicate(mode, _DebtCleared(stats), **kwargs)))
118
+
119
+
120
+ def _debt_only_token(owner, conn) -> "str | None":
121
+ key, _table = _DEBT_HOLD[owner.CONSUMER]
122
+ try:
123
+ row = conn.execute(
124
+ "SELECT value FROM main.cache_meta WHERE key=?", (key,)).fetchone()
125
+ except sqlite3.Error:
126
+ return None
127
+ return None if row is None else str(row[0])
128
+
129
+
130
+ def _prune_debt_present(owner, conn) -> "bool | None":
131
+ _key, table = _DEBT_HOLD[owner.CONSUMER]
132
+ try:
133
+ return conn.execute(
134
+ f"SELECT 1 FROM main.{table} LIMIT 1").fetchone() is not None
135
+ except sqlite3.Error:
136
+ return None
137
+
138
+
139
+ def _marker_extent(owner) -> "tuple[int, int, int] | None":
140
+ """The activity marker's (device, inode, size), or None when absent."""
141
+ try:
142
+ st = os.stat(activity_marker_path(owner.app_dir))
143
+ except OSError:
144
+ return None
145
+ return (st.st_dev, st.st_ino, st.st_size)
146
+
147
+
148
+ def _codex_activity_since(owner, extent) -> bool:
149
+ """Whether a Codex ticket reached the activity marker after ``extent``.
150
+
151
+ Review r3 F2: the Claude hook and the Codex hook append to one shared
152
+ marker (`record_activity`), so a moved extent alone would end the
153
+ conversation hold on any Claude Code activity. A ``codex`` record
154
+ appended after ``extent`` ends it, and so does a replaced marker (a
155
+ rotation or a recreated pathname, whose old tail this reader can no
156
+ longer see) or a removed one (`invalidate_activity_marker` runs after a
157
+ writer could not append its ticket). A marker that was absent at the plan
158
+ is read from its start. A slice that cannot be read or decoded ends the
159
+ hold too: failing open costs one walk, failing closed could hide a Codex
160
+ ticket for the rest of the hold. The slice holds at most one hold period
161
+ of tickets, because the hold ends FRONTIER_DEBT_HOLD_SECONDS after the
162
+ plan that recorded ``extent``.
163
+ """
164
+ if _marker_extent(owner) == extent:
165
+ return False
166
+ try:
167
+ with activity_marker_path(owner.app_dir).open("rb") as fh:
168
+ st = os.fstat(fh.fileno())
169
+ if extent is None:
170
+ start = 0
171
+ elif ((st.st_dev, st.st_ino) != extent[:2]
172
+ or st.st_size < extent[2]):
173
+ return True # replaced
174
+ else:
175
+ start = extent[2]
176
+ fh.seek(start)
177
+ raw = fh.read()
178
+ except OSError:
179
+ return True # removed or unreadable
180
+ complete = raw[:raw.rfind(b"\n") + 1] # a writer may be mid-append
181
+ try:
182
+ records = _decode_marker_records(complete)
183
+ except ValueError:
184
+ return True
185
+ return any(record[0] == "codex" for record in records)
186
+
187
+
188
+ def _debt_hold_plan(owner, provider: str, conn) -> "FrontierPlan | None":
189
+ """Hold instead of re-walking a debt-only Codex store (spec 5.7; N3).
190
+
191
+ The certificate stays withheld while prune debt is pending, so without
192
+ this every tick re-planned a full walk until the drain finished: a ~7 s
193
+ median walk on the production estate, the unconditional per-tick walk
194
+ FRONTIER_CERTIFICATE_MAX_AGE_SECONDS' note measured at ~44 % of a core.
195
+ The hold answers ``caught_up`` (reason ``debt_hold``) while this owner's
196
+ last full plan is younger than FRONTIER_DEBT_HOLD_SECONDS, a full walk
197
+ has written a debt-only token since that plan, and debt is still
198
+ pending. It drops any certificate this
199
+ owner holds (none may stand while debt is pending), and an empty debt
200
+ table ends it at once, so the walk that certifies the store follows the
201
+ drain immediately. The conversation frontier's hold also ends when a
202
+ Codex ticket reached the activity marker since that plan, or the marker
203
+ was replaced or removed (`_codex_activity_since`; a Claude ticket does
204
+ not end it, review r3 F2): no hook writes conversations.db, so Codex
205
+ hook activity is transcript content only a walk can publish. The
206
+ accounting frontier's hold does not read tickets at all; the bounded
207
+ delay that costs is a stated residual (review r3 F3).
208
+ """
209
+ if provider != "codex":
210
+ return None
211
+ last = getattr(owner, "_debt_walks", {}).get(provider)
212
+ if last is None or _now() - last[1] >= FRONTIER_DEBT_HOLD_SECONDS:
213
+ return None
214
+ state = owner._states.get(provider)
215
+ if state is not None and state.seeded_at >= last[1]:
216
+ # The last full walk certified, and a certifying walk deletes the
217
+ # token: the steady state answers here, with no database read.
218
+ return None
219
+ token = _debt_only_token(owner, conn)
220
+ if token is None or token == last[0]:
221
+ return None
222
+ if _prune_debt_present(owner, conn) is not True:
223
+ return None
224
+ if (owner.CONSUMER == "conversation"
225
+ and _codex_activity_since(owner, last[2])):
226
+ return None
227
+ owner._generations.drop(provider, owner.CONSUMER)
228
+ owner._states.pop(provider, None)
229
+ return FrontierPlan(provider, "caught_up", reason="debt_hold")
230
+
76
231
  _PENDING_META_KEYS = (
77
232
  "conversation_backfill_pending",
78
233
  "conversation_sessions_backfill_pending",
@@ -263,11 +418,127 @@ def classify_ledger_continuity(
263
418
 
264
419
 
265
420
  class ObservedFile(NamedTuple):
266
- """One restat of a tracked rollout: what is at that pathname right now."""
421
+ """One restat of a tracked rollout: what is at that pathname right now.
422
+
423
+ #968 spec 5.9: ``size`` is in DECODED space. A compressed container's byte
424
+ length is never compared with a decoded cursor: an accepted compressed
425
+ container reports the cursor's validated decoded size, and any other
426
+ compressed observation is pending container work (``container_pending``),
427
+ ``container_known`` when a verification already holds that exact
428
+ container.
429
+ """
267
430
 
268
431
  size: int
269
432
  device: int
270
433
  inode: int
434
+ container_pending: bool = False
435
+ container_known: bool = False
436
+ kind: str = "plain"
437
+ mtime_ns: "int | None" = None
438
+
439
+
440
+ class ContainerEvidence(NamedTuple):
441
+ """The container a cursor row last accepted, and what it holds pending."""
442
+
443
+ kind: str
444
+ container_size: "int | None"
445
+ decoded_size: "int | None"
446
+ mtime_ns: "int | None"
447
+ device: "int | None"
448
+ inode: "int | None"
449
+ state: str
450
+ pending_fingerprint: "str | None"
451
+
452
+ def _same_container(self, kind: str, st) -> bool:
453
+ if kind != self.kind or source_identity_replaced(
454
+ self.device, self.inode, st.st_dev, st.st_ino):
455
+ return False
456
+ if kind == _rc.PLAIN:
457
+ return True
458
+ # Review P3-2: a compressed container also needs the exact device,
459
+ # matching the walk's container-unchanged check and the generation
460
+ # token (Task 1, Task 3).
461
+ return (self.device is not None and int(self.device) == st.st_dev
462
+ and self.container_size == st.st_size
463
+ and self.mtime_ns == st.st_mtime_ns
464
+ and self.decoded_size is not None)
465
+
466
+ def accepts(self, kind: str, st) -> bool:
467
+ return self.state == "accepted" and self._same_container(kind, st)
468
+
469
+ def holds(self, kind: str, st) -> bool:
470
+ if self.state not in ("pending", "unverified"):
471
+ return False
472
+ if not self.pending_fingerprint: # a hold on the accepted container
473
+ return self._same_container(kind, st)
474
+ fp = _rc.ContainerFingerprint.from_json(self.pending_fingerprint)
475
+ return (fp is not None and fp.kind == kind and fp.size == st.st_size
476
+ and fp.mtime_ns == st.st_mtime_ns
477
+ and fp.device == st.st_dev and fp.inode == st.st_ino)
478
+
479
+
480
+ _CONTAINER_EVIDENCE_COLUMNS = (
481
+ "path, container_kind, container_size_bytes, decoded_size_bytes, "
482
+ "mtime_ns, device_id, inode, container_state, pending_container_fingerprint")
483
+
484
+
485
+ def _container_evidence_for(conn, table: str, paths) -> "dict[str, ContainerEvidence]":
486
+ """Container evidence for the named cursor rows, one chunked indexed read.
487
+
488
+ No paths, no statement: an idle tick reads nothing. A store without the
489
+ #968 columns (an unmigrated opener, a hand-built fixture) has no
490
+ container evidence, which is exactly the plain-only world.
491
+ """
492
+ if table not in _CODEX_CURSOR_TABLES:
493
+ raise ValueError("unknown codex cursor table")
494
+ wanted = sorted({str(p) for p in paths if p})
495
+ out: "dict[str, ContainerEvidence]" = {}
496
+ try:
497
+ for start in range(0, len(wanted), _CURSOR_QUERY_CHUNK):
498
+ chunk = wanted[start:start + _CURSOR_QUERY_CHUNK]
499
+ placeholders = ",".join("?" for _ in chunk)
500
+ for row in conn.execute(
501
+ f"SELECT {_CONTAINER_EVIDENCE_COLUMNS} FROM {table} "
502
+ f"WHERE path IN ({placeholders})", chunk):
503
+ out[str(row[0])] = ContainerEvidence(
504
+ row[1] or _rc.PLAIN, row[2], row[3], row[4], row[5],
505
+ row[6], row[7] or "accepted", row[8])
506
+ except sqlite3.OperationalError:
507
+ return {}
508
+ return out
509
+
510
+
511
+ def _observe_logical(path: str, evidence: "ContainerEvidence | None"):
512
+ """Restat one LOGICAL rollout through its selected container (5.2, 5.9).
513
+
514
+ Plain wins and keeps today's single ``pathlib`` stat, so a plain estate
515
+ is observed exactly as before; the ``.zst`` twin is statted only when the
516
+ plain file is absent.
517
+ """
518
+ kind = _rc.PLAIN
519
+ try:
520
+ st = pathlib.Path(path).stat()
521
+ except OSError:
522
+ try:
523
+ st = pathlib.Path(_rc.zstd_twin(path)).stat()
524
+ except OSError:
525
+ return None
526
+ kind = _rc.ZSTD
527
+ if evidence is not None and evidence.accepts(kind, st):
528
+ size = (st.st_size if kind == _rc.PLAIN
529
+ else int(evidence.decoded_size))
530
+ return ObservedFile(size, st.st_dev, st.st_ino, kind=kind,
531
+ mtime_ns=st.st_mtime_ns)
532
+ if kind == _rc.PLAIN and (evidence is None or (
533
+ evidence.state == "accepted" and evidence.kind == _rc.PLAIN)):
534
+ # Today's plain evidence: append, shrink and replacement are decided
535
+ # by the existing classifier from size and identity.
536
+ return ObservedFile(st.st_size, st.st_dev, st.st_ino, kind=kind,
537
+ mtime_ns=st.st_mtime_ns)
538
+ return ObservedFile(
539
+ 0, st.st_dev, st.st_ino, container_pending=True,
540
+ container_known=evidence is not None and evidence.holds(kind, st),
541
+ kind=kind, mtime_ns=st.st_mtime_ns)
271
542
 
272
543
 
273
544
  def source_identity_replaced(
@@ -325,7 +596,7 @@ def classify_recent_active_path(
325
596
  """Classify one recently-active rollout against its committed cursor.
326
597
 
327
598
  Pure. Returns one of ``unchanged``, ``append``, ``resume``, ``replaced``,
328
- ``cursor_gap`` or ``vanished``.
599
+ ``cursor_gap``, ``vanished`` or ``container_pending``.
329
600
 
330
601
  IDENTITY OUTRANKS SIZE. A larger file at a different inode is a
331
602
  replacement, not an append: its first byte is not the continuation of the
@@ -339,6 +610,9 @@ def classify_recent_active_path(
339
610
  """
340
611
  if observed is None:
341
612
  return "vanished"
613
+ if getattr(observed, "container_pending", False):
614
+ # Pending container work is decided by verification, never identity.
615
+ return "container_pending"
342
616
  if source_identity_replaced(
343
617
  committed_device, committed_inode, observed.device, observed.inode,
344
618
  ):
@@ -506,13 +780,10 @@ def _bump_counter(name: str, amount: int = 1) -> None:
506
780
  _FRONTIER_COUNTERS[name] = _FRONTIER_COUNTERS.get(name, 0) + amount
507
781
 
508
782
 
509
- def _observe_file(path: str, table: str) -> "ObservedFile | None":
783
+ def _observe_file(path: str, table: str,
784
+ evidence: "ContainerEvidence | None" = None) -> "ObservedFile | None":
510
785
  _bump_counter(f"retained_file_visits.{table}")
511
- try:
512
- st = pathlib.Path(path).stat()
513
- except OSError:
514
- return None
515
- return ObservedFile(st.st_size, st.st_dev, st.st_ino)
786
+ return _observe_logical(path, evidence)
516
787
 
517
788
 
518
789
  #: The two Codex cursor tables the restat may read, and whether each carries
@@ -572,19 +843,25 @@ def plan_recent_active_restat(
572
843
  # `sqlite3.DatabaseError` and an escape here would end the tick.
573
844
  return frozenset(), "codex_cursor_unavailable"
574
845
  targets: set[str] = set()
846
+ evidence = _container_evidence_for(conn, table, candidates)
575
847
  for path, (size, offset, complete, device, inode) in sorted(
576
848
  candidates.items()
577
849
  ):
850
+ observed = _observe_file(path, table, evidence.get(path))
578
851
  verdict = classify_recent_active_path(
579
852
  committed_size=size,
580
853
  committed_offset=offset,
581
854
  committed_complete=complete,
582
855
  committed_device=device,
583
856
  committed_inode=inode,
584
- observed=_observe_file(path, table),
857
+ observed=observed,
585
858
  )
586
859
  if verdict in {"append", "resume"}:
587
860
  targets.add(path)
861
+ elif verdict == "container_pending":
862
+ # New transitions request verification once; known holds converge.
863
+ if not observed.container_known:
864
+ targets.add(path)
588
865
  elif verdict == "replaced":
589
866
  return frozenset(), "source_replaced"
590
867
  elif verdict == "cursor_gap":
@@ -692,13 +969,23 @@ def _configuration_generation_reason(
692
969
 
693
970
 
694
971
  def _ticket_target_paths(provider: str, records, roots):
695
- """The ticketed paths this plan may target, or a refusal reason."""
972
+ """The ticketed paths this plan may target, or a refusal reason.
973
+
974
+ #968 spec 5.9: a Codex ticket may name either spelling; it is
975
+ normalized to ONE logical target, and containment is enforced on the
976
+ selected physical container.
977
+ """
696
978
  provider_paths = tuple(
697
979
  path for kind, path, _e, _s, _c in records if kind == provider)
698
980
  if any(not path for path in provider_paths):
699
981
  return None, "ambiguous_activity"
982
+ if provider == "codex":
983
+ provider_paths = tuple(
984
+ os.fspath(_rc.logical_path(path)) for path in provider_paths)
700
985
  paths = frozenset(provider_paths)
701
- if any(not _target_in_roots(path, roots) for path in paths):
986
+ if any(not _target_in_roots(path, roots,
987
+ allow_compressed=provider == "codex")
988
+ for path in paths):
702
989
  return None, "target_outside_scope"
703
990
  return paths, None
704
991
 
@@ -771,6 +1058,12 @@ def _counted_plan(owner, body, provider, conn, **kwargs):
771
1058
  _bump_counter(f"plans.{consumer}.{provider}.{plan.mode}")
772
1059
  if plan.mode == "full":
773
1060
  _bump_counter(f"full_walks.{consumer}.{provider}")
1061
+ debt_walks = getattr(owner, "_debt_walks", None)
1062
+ if provider == "codex" and debt_walks is not None:
1063
+ # Review N3: the debt-only token this full plan saw, when, and
1064
+ # the activity marker's extent at that moment.
1065
+ debt_walks[provider] = (
1066
+ _debt_only_token(owner, conn), _now(), _marker_extent(owner))
774
1067
  if plan.paths:
775
1068
  _bump_counter(
776
1069
  f"targeted_paths.{consumer}.{provider}", len(plan.paths))
@@ -1699,19 +1992,41 @@ def _target_has_cursor_gap(conn, provider: str, source_path: str) -> bool:
1699
1992
  ).fetchone()
1700
1993
  if row is None:
1701
1994
  return False # a genuinely new file is a valid targeted append
1995
+ floor = max(int(row[0] or 0), int(row[1] or 0))
1996
+ if provider == "codex":
1997
+ observed = _observe_logical(
1998
+ source_path,
1999
+ _container_evidence_for(conn, table, [source_path]).get(source_path))
2000
+ if observed is None:
2001
+ return True
2002
+ if observed.container_pending:
2003
+ return False # pending container work, not a missing-file gap
2004
+ return observed.size < floor
1702
2005
  try:
1703
2006
  actual_size = pathlib.Path(source_path).stat().st_size
1704
2007
  except OSError:
1705
2008
  return True
1706
- return actual_size < max(int(row[0] or 0), int(row[1] or 0))
2009
+ return actual_size < floor
1707
2010
 
1708
2011
 
1709
- def _target_in_roots(source_path: str, roots) -> bool:
2012
+ def _target_in_roots(source_path: str, roots, *,
2013
+ allow_compressed: bool = False) -> bool:
1710
2014
  path = pathlib.Path(source_path)
1711
- if not path.is_absolute() or path.suffix != ".jsonl":
2015
+ if not path.is_absolute():
1712
2016
  return False
2017
+ if allow_compressed:
2018
+ if not _rc.is_container_name(path.name):
2019
+ return False
2020
+ selected = _rc.select_container(path)
2021
+ if selected is None:
2022
+ return False
2023
+ physical = selected.path
2024
+ else:
2025
+ if path.suffix != ".jsonl":
2026
+ return False
2027
+ physical = path
1713
2028
  try:
1714
- resolved = path.resolve(strict=True)
2029
+ resolved = physical.resolve(strict=True)
1715
2030
  except OSError:
1716
2031
  return False
1717
2032
  if not resolved.is_file():
@@ -1752,8 +2067,11 @@ def provider_sync_certifiable(mode: str, stats: object) -> bool:
1752
2067
  return False
1753
2068
  common_clean = not any((
1754
2069
  getattr(stats, "lock_contended", False),
2070
+ getattr(stats, "barrier_retry", False),
1755
2071
  getattr(stats, "files_failed", 0),
1756
2072
  getattr(stats, "files_deferred_torn", 0),
2073
+ getattr(stats, "container_pending", 0),
2074
+ getattr(stats, "files_deferred_reset", 0),
1757
2075
  getattr(stats, "deferred_reason", None),
1758
2076
  getattr(stats, "prune_refused", False),
1759
2077
  getattr(stats, "budget_exhausted", False),
@@ -1777,6 +2095,7 @@ class DashboardIngestFrontier:
1777
2095
  def __init__(self, app_dir: pathlib.Path):
1778
2096
  self.app_dir = pathlib.Path(app_dir)
1779
2097
  self._states: dict[str, _ProviderState] = {}
2098
+ self._debt_walks: dict = {}
1780
2099
  self.last_seed_failure: dict[str, str] = {}
1781
2100
  self._fallback_count = 0
1782
2101
  self._generations = frontier_generations(self.app_dir)
@@ -1923,6 +2242,9 @@ class DashboardIngestFrontier:
1923
2242
  self, provider: str, conn, *, roots, guard_paths=(),
1924
2243
  configuration_generation=_DERIVE_CONFIGURATION_GENERATION,
1925
2244
  ) -> FrontierPlan:
2245
+ hold = _debt_hold_plan(self, provider, conn)
2246
+ if hold is not None:
2247
+ return hold
1926
2248
  state = self._states.get(provider)
1927
2249
  if state is None:
1928
2250
  return FrontierPlan(provider, "full", reason="unseeded")
@@ -2079,12 +2401,29 @@ def _conversation_target_risk(conn, provider: str, source_path: str) -> str | No
2079
2401
  ).fetchone()
2080
2402
  if row is None:
2081
2403
  return None
2404
+ stored_size = int(row[0] or 0)
2405
+ floor = max(stored_size, int(row[2] or 0))
2406
+ if provider == "codex":
2407
+ # #968 spec 5.9: the same container-aware contract as the accounting
2408
+ # planner, so the two planners converge.
2409
+ observed = _observe_logical(
2410
+ source_path,
2411
+ _container_evidence_for(conn, table, [source_path]).get(source_path))
2412
+ if observed is None:
2413
+ return "cursor_gap"
2414
+ if observed.container_pending:
2415
+ return None
2416
+ if observed.size < floor:
2417
+ return "cursor_gap"
2418
+ if (observed.kind == _rc.PLAIN and observed.size == stored_size
2419
+ and observed.mtime_ns != int(row[1] or 0)):
2420
+ return "source_replaced"
2421
+ return None
2082
2422
  try:
2083
2423
  stat = pathlib.Path(source_path).stat()
2084
2424
  except OSError:
2085
2425
  return "cursor_gap"
2086
- stored_size = int(row[0] or 0)
2087
- if stat.st_size < max(stored_size, int(row[2] or 0)):
2426
+ if stat.st_size < floor:
2088
2427
  return "cursor_gap"
2089
2428
  if stat.st_size == stored_size and stat.st_mtime_ns != int(row[1] or 0):
2090
2429
  return "source_replaced"
@@ -2101,8 +2440,11 @@ def conversation_sync_certifiable(
2101
2440
  return False
2102
2441
  if any((
2103
2442
  getattr(stats, "lock_contended", False),
2443
+ getattr(stats, "barrier_retry", False),
2104
2444
  getattr(stats, "files_failed", 0),
2105
2445
  getattr(stats, "files_deferred_torn", 0),
2446
+ getattr(stats, "container_pending", 0),
2447
+ getattr(stats, "container_reads_held", 0),
2106
2448
  getattr(stats, "deferred_reason", None),
2107
2449
  getattr(stats, "prune_refused", False),
2108
2450
  getattr(stats, "budget_exhausted", False),
@@ -2110,9 +2452,12 @@ def conversation_sync_certifiable(
2110
2452
  )):
2111
2453
  return False
2112
2454
  total = int(getattr(stats, "files_total", 0) or 0)
2455
+ held = sum((getattr(stats, "container_skipped", None) or {}).values())
2113
2456
  completed = (
2114
2457
  int(getattr(stats, "files_processed", 0) or 0)
2115
2458
  + int(getattr(stats, "files_skipped_unchanged", 0) or 0)
2459
+ + int(held)
2460
+ + int(getattr(stats, "barrier_files_deferred", 0) or 0)
2116
2461
  )
2117
2462
  if mode == "targeted":
2118
2463
  # A source deleted after planning is filtered before the sync sees it;
@@ -2121,10 +2466,39 @@ def conversation_sync_certifiable(
2121
2466
  if mode == "full":
2122
2467
  # There is no durable conversation-walk sentinel. The per-call census
2123
2468
  # is therefore the proof that the exhaustive discovery actually drained.
2124
- return completed == total
2469
+ return completed == total and not getattr(stats, "prune_debt_pending", 0)
2125
2470
  return False
2126
2471
 
2127
2472
 
2473
+ def _codex_publication_backlog(conn) -> "tuple[bool, frozenset[str]]":
2474
+ """Certificates newer than their publication (#968 spec 5.11).
2475
+
2476
+ A seal token the conversations store has not acknowledged means a
2477
+ publication pass is due; its contributor paths are the files that pass
2478
+ must read. Any read failure answers "nothing due": the conversation pass
2479
+ itself fails closed.
2480
+ """
2481
+ import _lib_codex_publication_barrier as barrier
2482
+ try:
2483
+ sealed = conn.execute("SELECT value FROM cache_db.cache_meta WHERE key=?",
2484
+ (barrier.SEAL_SEQ_KEY,)).fetchone()
2485
+ if sealed is None:
2486
+ return False, frozenset()
2487
+ seen = conn.execute("SELECT value FROM main.cache_meta WHERE key=?",
2488
+ (barrier.PUBLISHED_SEQ_KEY,)).fetchone()
2489
+ if seen is not None and seen[0] == sealed[0]:
2490
+ return False, frozenset()
2491
+ paths: set = set()
2492
+ for _key, _revision, text in conn.execute(
2493
+ barrier.BACKLOG_CERTIFICATES_SQL):
2494
+ cert = barrier.parse_certificate(text)
2495
+ if cert is not None:
2496
+ paths.update(c["path"] for c in cert["contributors"])
2497
+ return True, frozenset(paths)
2498
+ except sqlite3.Error:
2499
+ return False, frozenset()
2500
+
2501
+
2128
2502
  class ConversationSyncFrontier:
2129
2503
  """Independent hook-journal certificates for `conversations.db`.
2130
2504
 
@@ -2139,6 +2513,7 @@ class ConversationSyncFrontier:
2139
2513
  def __init__(self, app_dir: pathlib.Path):
2140
2514
  self.app_dir = pathlib.Path(app_dir)
2141
2515
  self._states: dict[str, _ProviderState] = {}
2516
+ self._debt_walks: dict = {}
2142
2517
  self.last_seed_failure: dict[str, str] = {}
2143
2518
  self._fallback_count = 0
2144
2519
  self._generations = frontier_generations(self.app_dir)
@@ -2257,6 +2632,17 @@ class ConversationSyncFrontier:
2257
2632
  self, provider: str, conn, *, roots, guard_paths=(),
2258
2633
  configuration_generation=_DERIVE_CONFIGURATION_GENERATION,
2259
2634
  ):
2635
+ hold = _debt_hold_plan(self, provider, conn)
2636
+ if hold is not None:
2637
+ if provider == "codex":
2638
+ due, backlog = _codex_publication_backlog(conn)
2639
+ if due:
2640
+ # The hold withholds a certificate, not a publication the
2641
+ # cache has sealed; with no certificate held,
2642
+ # commit_provider ignores the targeted result.
2643
+ return replace(hold, mode="targeted", paths=backlog,
2644
+ reason="publication_backlog")
2645
+ return hold
2260
2646
  state = self._states.get(provider)
2261
2647
  if state is None:
2262
2648
  return FrontierPlan(provider, "full", reason="unseeded")
@@ -2313,11 +2699,19 @@ class ConversationSyncFrontier:
2313
2699
  return FrontierPlan(provider, "full", reason="source_replaced")
2314
2700
  if "cursor_gap" in target_risks:
2315
2701
  return FrontierPlan(provider, "full", reason="cursor_gap")
2316
- return _restat_and_return(
2702
+ plan = _restat_and_return(
2317
2703
  provider, conn, cursor_table=_conversation_source_table(provider),
2318
2704
  paths=paths, marker_end=marker_end, ledger_epoch=ledger_epoch,
2319
2705
  ledger_sequence=ledger_sequence,
2320
2706
  )
2707
+ if provider == "codex" and plan.mode in ("caught_up", "targeted"):
2708
+ due, backlog = _codex_publication_backlog(conn)
2709
+ if due:
2710
+ plan = replace(
2711
+ plan, mode="targeted", paths=plan.paths | backlog,
2712
+ reason=("publication_backlog" if plan.mode == "caught_up"
2713
+ else plan.reason))
2714
+ return plan
2321
2715
 
2322
2716
  def commit_provider(
2323
2717
  self, plan: FrontierPlan, conn, *, roots, guard_paths=(), trusted=True,