cctally 1.117.2 → 1.118.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 (58) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +7 -3
  3. package/bin/_cctally_cache.py +2783 -175
  4. package/bin/_cctally_completion.py +106 -33
  5. package/bin/_cctally_core.py +69 -4
  6. package/bin/_cctally_dashboard.py +188 -13
  7. package/bin/_cctally_dashboard_conversation.py +5 -1
  8. package/bin/_cctally_dashboard_envelope.py +50 -19
  9. package/bin/_cctally_dashboard_sources.py +23 -1
  10. package/bin/_cctally_db.py +193 -153
  11. package/bin/_cctally_diagnosis_sources.py +63 -7
  12. package/bin/_cctally_doctor.py +865 -93
  13. package/bin/_cctally_five_hour.py +67 -50
  14. package/bin/_cctally_forecast.py +35 -13
  15. package/bin/_cctally_journal.py +186 -18
  16. package/bin/_cctally_milestone_history.py +859 -62
  17. package/bin/_cctally_oauth_samples.py +286 -15
  18. package/bin/_cctally_parser.py +2 -2
  19. package/bin/_cctally_quota.py +104 -12
  20. package/bin/_cctally_record.py +153 -29
  21. package/bin/_cctally_rederive.py +608 -68
  22. package/bin/_cctally_statusline.py +108 -81
  23. package/bin/_cctally_store.py +179 -38
  24. package/bin/_cctally_tui.py +892 -181
  25. package/bin/_cctally_update.py +32 -13
  26. package/bin/_lib_codex_conversation_query.py +52 -14
  27. package/bin/_lib_codex_drain_admission.py +224 -6
  28. package/bin/_lib_codex_metadata.py +39 -0
  29. package/bin/_lib_conversation_anon.py +15 -8
  30. package/bin/_lib_conversation_dispatch.py +7 -0
  31. package/bin/_lib_conversation_query.py +289 -70
  32. package/bin/_lib_dashboard_sources.py +37 -1
  33. package/bin/_lib_detector_access_registry.py +497 -26
  34. package/bin/_lib_doctor.py +85 -20
  35. package/bin/_lib_doctor_scan_index.py +321 -0
  36. package/bin/_lib_ingest_frontier.py +20 -1
  37. package/bin/_lib_oauth_samples.py +23 -7
  38. package/bin/_lib_pricing.py +146 -16
  39. package/bin/_lib_quota.py +19 -0
  40. package/bin/_lib_rederive.py +11 -6
  41. package/bin/_lib_retained_records.py +516 -0
  42. package/bin/_lib_snapshot_cache.py +10 -0
  43. package/bin/_lib_stale_proof.py +272 -43
  44. package/bin/_lib_stats_content_revision.py +831 -0
  45. package/bin/_lib_write_io.py +11 -4
  46. package/bin/cctally +4 -1
  47. package/dashboard/static/assets/ConversationsView-D4Iv4ku0.js +72 -0
  48. package/dashboard/static/assets/{DoctorModal-B0DnU9d9.js → DoctorModal-D4QSq6uz.js} +1 -1
  49. package/dashboard/static/assets/ModalRoot-C9F5dhZi.js +1 -0
  50. package/dashboard/static/assets/{ProjectsDrillPanel-DSxXQSa-.js → ProjectsDrillPanel-WxAdRDMt.js} +1 -1
  51. package/dashboard/static/assets/{SourceDetailModal-BleDpQqG.js → SourceDetailModal-NyyEfXmc.js} +1 -1
  52. package/dashboard/static/assets/{UpdateModal-CMqaAkSK.js → UpdateModal-CFLq9pT1.js} +1 -1
  53. package/dashboard/static/assets/{index-BjQV-0-D.js → index-BKTWH9JE.js} +2 -2
  54. package/dashboard/static/assets/{outlineNavigation-CO-VWBm7.js → outlineNavigation-BnGUwCS3.js} +3 -3
  55. package/dashboard/static/dashboard.html +1 -1
  56. package/package.json +4 -1
  57. package/dashboard/static/assets/ConversationsView-oqNsWzWV.js +0 -72
  58. package/dashboard/static/assets/ModalRoot-CYua2wP_.js +0 -1
@@ -2882,12 +2882,17 @@ class _DashboardUpdateCheckThread(threading.Thread):
2882
2882
  hub: "SSEHub | None" = None,
2883
2883
  snapshot_ref: "_SnapshotRef | None" = None,
2884
2884
  runtime_bind: "str | None" = None,
2885
+ sync_lock: "threading.Lock | None" = None,
2885
2886
  ) -> None:
2886
2887
  super().__init__(name="cctally-update-check")
2887
2888
  self._stop_event = stop_event
2888
2889
  self._hub = hub
2889
2890
  self._ref = snapshot_ref
2890
2891
  self._runtime_bind = runtime_bind
2892
+ # #932 §4.2: the dashboard's publisher lock, so this republish cannot
2893
+ # land after (and supersede) a newer locked publish. ``None`` keeps
2894
+ # the unserialized behaviour for callers that publish nothing else.
2895
+ self._sync_lock = sync_lock
2891
2896
 
2892
2897
  def run(self) -> None:
2893
2898
  c = _cctally()
@@ -2953,24 +2958,38 @@ class _DashboardUpdateCheckThread(threading.Thread):
2953
2958
  because a full sync can publish while the precomputes above are being
2954
2959
  gathered; writing a whole snapshot read before that sync would regress
2955
2960
  every data field and can restore a partial hydration seed.
2961
+
2962
+ #932 §4.2: the precomputes stay outside ``sync_lock``; the patch and
2963
+ its publish run inside it (blocking), so they are serialized with every
2964
+ snapshot publisher. The hub's latest delivery is what ``/api/data`` and
2965
+ new subscribers read, and without the lock a republish of an older
2966
+ deferred object could land after the settle publish that replaced it.
2956
2967
  """
2968
+ import contextlib
2957
2969
  c = _cctally()
2970
+ tui = c._cctally_tui
2958
2971
  c._load_sibling("_lib_snapshot_cache").reset_doctor_memo()
2959
- doctor_payload = c._cctally_tui._tui_precompute_doctor_payload(
2972
+ doctor_payload = tui._tui_precompute_doctor_payload(
2960
2973
  _now_utc(), self._runtime_bind,
2961
2974
  )
2962
- fresh = self._ref.replace_fields(
2963
- envelope_precompute=(
2964
- c._cctally_tui._tui_precompute_envelope_config(load_config())
2965
- ),
2966
- doctor_payload=doctor_payload,
2967
- # #278 §1.4.1: a version-banner refresh republishes complete data;
2968
- # force the hydration latch clear so a republish that happens to
2969
- # carry a prior hydrating seed/partial doesn't freeze the client's
2970
- # loading skeletons.
2971
- hydrating=False,
2972
- )
2973
- self._hub.publish(fresh)
2975
+ envelope_precompute = tui._tui_precompute_envelope_config(load_config())
2976
+ lock = (self._sync_lock if self._sync_lock is not None
2977
+ else contextlib.nullcontext())
2978
+ with lock:
2979
+ held = self._ref.get()
2980
+ fresh = self._ref.replace_fields(
2981
+ envelope_precompute=envelope_precompute,
2982
+ doctor_payload=doctor_payload,
2983
+ # #278 §1.4.1: a version-banner refresh republishes complete
2984
+ # data; force the hydration latch clear so a republish that
2985
+ # happens to carry a prior hydrating seed/partial doesn't
2986
+ # freeze the client's loading skeletons. #932 §4.2: except
2987
+ # over an explicit stats deferral, which is still loading.
2988
+ hydrating=(held.hydrating
2989
+ if tui._tui_snapshot_is_stats_deferral(held)
2990
+ else False),
2991
+ )
2992
+ self._hub.publish(fresh)
2974
2993
 
2975
2994
 
2976
2995
  def cmd_update(args) -> int:
@@ -52,12 +52,14 @@ from _lib_codex_find_projection import (
52
52
  from _lib_codex_metadata import (
53
53
  codex_metadata_is_malformed,
54
54
  decode_codex_project_metadata,
55
+ derive_codex_git_branch,
55
56
  resolve_codex_threads_table,
56
57
  )
57
58
  from _lib_codex_title_clean import clean_codex_title
58
59
  from _lib_conversation import _strip_ansi
59
60
  from _lib_conversation_query import (
60
- _FULL_PAYLOAD_CEILING, _first_nonblank_line, _parse_outline_ts)
61
+ _FULL_PAYLOAD_CEILING, _first_nonblank_line, _parse_outline_ts,
62
+ plain_snippet_parts)
61
63
  from _lib_pricing import _calculate_codex_entry_cost
62
64
  from _lib_retained_size import retained_size_bytes
63
65
 
@@ -1648,15 +1650,38 @@ def _display_chain(fields: dict) -> str:
1648
1650
  or _short_native(fields.get("native_thread_id")) or "")
1649
1651
 
1650
1652
 
1651
- def _conversation_display_title(
1653
+ def _conversation_header_fields(
1652
1654
  conn: sqlite3.Connection, conversation_key: str, rows: list | None = None,
1653
1655
  *, undecodable_keys: "frozenset[str]" = frozenset(),
1654
- ) -> str:
1656
+ ) -> "tuple[str, str | None]":
1657
+ """``(display title, project_label)`` from ONE ``_rollup_fields`` resolution
1658
+ (#1037): the project label is the value the rail shows (stored rollup or the
1659
+ identical live recompute, under the request's undecodable-key exclusion)."""
1655
1660
  fields = _rollup_fields(
1656
1661
  conn, conversation_key, rows=rows, undecodable_keys=undecodable_keys)
1657
1662
  if fields is None:
1658
- return ""
1659
- return _display_chain(fields)
1663
+ return "", None
1664
+ return _display_chain(fields), fields.get("project_label")
1665
+
1666
+
1667
+ def _conversation_display_title(
1668
+ conn: sqlite3.Connection, conversation_key: str, rows: list | None = None,
1669
+ *, undecodable_keys: "frozenset[str]" = frozenset(),
1670
+ ) -> str:
1671
+ return _conversation_header_fields(
1672
+ conn, conversation_key, rows=rows, undecodable_keys=undecodable_keys)[0]
1673
+
1674
+
1675
+ def _conversation_branch(
1676
+ conn: sqlite3.Connection, conversation_key: str,
1677
+ ) -> "tuple[str | None, str]":
1678
+ """``(git_branch, git_branch_status)`` from the conversation's own thread row
1679
+ in the request's thread view (scoped, last-good), decoded field by field.
1680
+ Independent of the project attribution exclusion (#1037 R-P1)."""
1681
+ row = conn.execute(_THREAD_FACTS_SQL, (conversation_key,)).fetchone()
1682
+ if row is None:
1683
+ return derive_codex_git_branch(None)
1684
+ return derive_codex_git_branch(decode_codex_project_metadata(row[4], row[5]))
1660
1685
 
1661
1686
 
1662
1687
  def _conversation_hit_fields(
@@ -2491,15 +2516,22 @@ def get_codex_conversation(
2491
2516
  built.append(item)
2492
2517
  _attach_spawn_child_links(conn, conversation_key, built)
2493
2518
  page_items = built
2519
+ # NOT the Phase A rows: those carry no ``text``, and the live-recompute
2520
+ # fallback inside _rollup_fields derives the title from it. Passing None
2521
+ # keeps the stored fast path unchanged and lets the rare no-rollup case
2522
+ # do its own wide read rather than titling the conversation "".
2523
+ title, project_label = _conversation_header_fields(
2524
+ conn, conversation_key, undecodable_keys=undecodable_keys)
2525
+ git_branch, git_branch_status = _conversation_branch(conn, conversation_key)
2494
2526
  return {
2495
2527
  "status": "ok",
2496
2528
  "conversation_key": conversation_key,
2497
- # NOT the Phase A rows: those carry no ``text``, and the live-recompute
2498
- # fallback inside _rollup_fields derives the title from it. Passing None
2499
- # keeps the stored fast path unchanged and lets the rare no-rollup case
2500
- # do its own wide read rather than titling the conversation "".
2501
- "title": _conversation_display_title(
2502
- conn, conversation_key, undecodable_keys=undecodable_keys),
2529
+ "title": title,
2530
+ # #1037: the reader header's project slot (the rail's label) and branch
2531
+ # slot (the thread's own retained git_json, with its status).
2532
+ "project_label": project_label,
2533
+ "git_branch": git_branch,
2534
+ "git_branch_status": git_branch_status,
2503
2535
  "items": page_items,
2504
2536
  "page": page,
2505
2537
  "session_index": session_index,
@@ -4354,7 +4386,9 @@ def _collapse_message_hits(
4354
4386
  hit["snippet"] = _search_excerpt(disp, query)
4355
4387
  return [
4356
4388
  {"conversation_key": h["conversation_key"], "item_key": h["item_key"],
4357
- "title": h["title"], "snippet": h["snippet"], "badges": sorted(h["_badges"]),
4389
+ "title": h["title"], "snippet": h["snippet"],
4390
+ "snippet_parts": plain_snippet_parts(h["snippet"]),
4391
+ "badges": sorted(h["_badges"]),
4358
4392
  "last_activity_utc": h["last_activity_utc"], "project_label": h["project_label"]}
4359
4393
  for h in collapsed.values()
4360
4394
  ]
@@ -4379,9 +4413,11 @@ def _search_title(
4379
4413
  "SELECT conversation_key, title, last_activity_utc, project_label "
4380
4414
  "FROM codex_conversation_rollups WHERE title LIKE ?", (like,)):
4381
4415
  cleaned = clean_codex_title(title)
4416
+ snippet = _excerpt(cleaned)
4382
4417
  hits.append(
4383
4418
  {"conversation_key": ck, "item_key": None, "title": cleaned,
4384
- "snippet": _excerpt(cleaned), "badges": ["title"],
4419
+ "snippet": snippet, "snippet_parts": plain_snippet_parts(snippet),
4420
+ "badges": ["title"],
4385
4421
  "last_activity_utc": last_act,
4386
4422
  "project_label": (
4387
4423
  None if ck in undecodable_keys else project_label)})
@@ -4414,10 +4450,12 @@ def _search_files(
4414
4450
  conn, ck, undecodable_keys=undecodable_keys)
4415
4451
  item_key = pos_cache[ck].get((member[0], member[1]))
4416
4452
  title, last_act, project_label = fields_cache[ck]
4453
+ snippet = _excerpt(file_path)
4417
4454
  hit = collapsed.setdefault(
4418
4455
  (ck, item_key),
4419
4456
  {"conversation_key": ck, "item_key": item_key, "title": title,
4420
- "snippet": _excerpt(file_path), "badges": ["files"],
4457
+ "snippet": snippet, "snippet_parts": plain_snippet_parts(snippet),
4458
+ "badges": ["files"],
4421
4459
  "last_activity_utc": last_act, "project_label": project_label})
4422
4460
  return list(collapsed.values())
4423
4461
 
@@ -1,5 +1,10 @@
1
1
  """Waiting requests and the drain-admission gate (#968 spec 5.7, 5.12).
2
2
 
3
+ Drain workers yield to two signals, both reported by ``fresh_requests``:
4
+ immutable Codex requests that end only by expiry (below), and one
5
+ process-owned Claude waiter whose expiring record bounds it (#1055, after the
6
+ Codex request section).
7
+
3
8
  A Codex hook never waits for a lock. When it meets cache contention it
4
9
  publishes a uniquely named, immutable request record under the data
5
10
  directory (outside every SQLite store, because a contended hook cannot write
@@ -19,8 +24,24 @@ monotonic clock. A worker observing a request, or restarting, never renews it;
19
24
  a wall-clock jump cannot extend it (the wall age only shortens freshness, and
20
25
  a NEGATIVE wall age — an mtime ahead of the wall clock after a backward step —
21
26
  counts as fresh, bounded by the monotonic lifetime); a malformed record or one
22
- from an incompatible clock is expired and removed. Stdlib only; no cctally
23
- imports.
27
+ from an incompatible clock is expired and removed.
28
+
29
+ The Claude waiter (#1055 spec 4.1-4.3). A Claude hook whose single attempt on
30
+ ``cache.db.lock`` is refused while it sees an admitted drain unit (the Codex
31
+ provider lock and the gate both busy) registers on ``claude-cache-waiter.lock``:
32
+ one ``LOCK_EX | LOCK_NB`` attempt, never retried, then an owner-written
33
+ record ``cctally-claude-waiter v1 <pid> <expiry_ns>`` (expiry on this module's
34
+ monotonic clock). The record is written at registration (expiry: the hook's
35
+ acquisition deadline) and once on acquiring the writer lock (expiry: now plus
36
+ the hold allowance); nothing else renews it, least of all an observation.
37
+ Drains probe the file read-only with ``LOCK_SH | LOCK_NB``: the waiter is
38
+ fresh only while the probe is refused AND the record parses, is unexpired and
39
+ is at most ``max(T, hold allowance)`` ahead. A fresh waiter is reported as the
40
+ reserved sentinel after the Codex ids, so every admission check, recheck and
41
+ backoff yields to it unchanged; with no fresh waiter the value is exactly the
42
+ Codex ids. The release truncates the record before unlocking; a killed hook's
43
+ lock is released by the kernel; the file is never unlinked. Stdlib only; no
44
+ cctally imports.
24
45
  """
25
46
  from __future__ import annotations
26
47
 
@@ -39,6 +60,24 @@ GATE_FILE_NAME = "codex-drain-admission.lock"
39
60
  POLL_SECONDS = 5.0
40
61
  _RECORD_VERSION = 1
41
62
 
63
+ # #1055: the Claude waiter. Module constants read at call time (monkeypatchable,
64
+ # not configuration).
65
+ CLAUDE_WAITER_FILE_NAME = "claude-cache-waiter.lock"
66
+ #: Reserved id; a generated Codex id is ``<mono>-<pid>-<hex>``, never this.
67
+ CLAUDE_WAITER_SENTINEL = "claude-waiter"
68
+ #: T: how long a refused Claude hook may wait for ``cache.db.lock``.
69
+ CLAUDE_HOOK_DRAIN_WAIT_SECONDS = 5.0
70
+ CLAUDE_HOOK_DRAIN_POLL_SECONDS = 0.05
71
+ #: How long the waiter stays fresh once its hook holds ``cache.db.lock``.
72
+ CLAUDE_WAITER_HOLD_ALLOWANCE_SECONDS = 15.0
73
+ #: The drain's step while only the Claude waiter is fresh.
74
+ SHORT_POLL_SECONDS = 0.05
75
+ #: How recent an observed waiter must be for ``_drain_backoff``'s short step.
76
+ CLAUDE_WAITER_RECENT_SECONDS = 1.0
77
+ _WAITER_MAGIC = "cctally-claude-waiter"
78
+ _WAITER_VERSION = "v1"
79
+ _WAITER_RECORD_MAX = 128
80
+
42
81
  # Module attributes so tests can drive a deterministic schedule.
43
82
  _monotonic_ns: Callable[[], int] = time.monotonic_ns
44
83
  _sleep: Callable[[float], None] = time.sleep
@@ -46,6 +85,17 @@ _sleep: Callable[[float], None] = time.sleep
46
85
  #: are released. Production leaves it None; the fairness test synchronizes on
47
86
  #: it instead of sleeping (review P2-7).
48
87
  _after_finish: Optional[Callable[[], None]] = None
88
+ #: Per-process hint: ``os.path.realpath(app_dir)`` -> the ``_monotonic_ns``
89
+ #: time of the last probe that found a fresh waiter. It selects a sleep
90
+ #: interval only; empty checks never renew it.
91
+ _claude_waiter_observed: "dict[str, int]" = {}
92
+
93
+
94
+ def _clear_claude_waiter_hint() -> None:
95
+ _claude_waiter_observed.clear()
96
+
97
+
98
+ os.register_at_fork(after_in_child=_clear_claude_waiter_hint)
49
99
 
50
100
 
51
101
  def _request_dir(app_dir) -> pathlib.Path:
@@ -97,7 +147,18 @@ def _is_fresh(path: pathlib.Path, now_mono: int, now_wall: float) -> bool:
97
147
 
98
148
 
99
149
  def fresh_requests(app_dir) -> "list[str]":
100
- """Ids of fresh requests; expired or malformed records are removed one by one."""
150
+ """Ids of fresh requests; expired or malformed records are removed one by one.
151
+
152
+ A fresh Claude waiter follows the Codex ids as ``CLAUDE_WAITER_SENTINEL``;
153
+ it is probed even when the request directory is missing or unreadable.
154
+ """
155
+ fresh = _fresh_codex_requests(app_dir)
156
+ if claude_waiter_fresh(app_dir):
157
+ fresh.append(CLAUDE_WAITER_SENTINEL)
158
+ return fresh
159
+
160
+
161
+ def _fresh_codex_requests(app_dir) -> "list[str]":
101
162
  directory = _request_dir(app_dir)
102
163
  try:
103
164
  entries = sorted(directory.iterdir())
@@ -127,6 +188,154 @@ def fresh_requests(app_dir) -> "list[str]":
127
188
  return fresh
128
189
 
129
190
 
191
+ def _waiter_path(app_dir) -> pathlib.Path:
192
+ return pathlib.Path(app_dir) / CLAUDE_WAITER_FILE_NAME
193
+
194
+
195
+ def _hint_key(app_dir) -> str:
196
+ return os.path.realpath(os.fspath(app_dir))
197
+
198
+
199
+ def _waiter_bound_ns() -> int:
200
+ return round(max(CLAUDE_HOOK_DRAIN_WAIT_SECONDS,
201
+ CLAUDE_WAITER_HOLD_ALLOWANCE_SECONDS) * 1e9)
202
+
203
+
204
+ def _parse_waiter_expiry(data: bytes) -> "int | None":
205
+ try:
206
+ text = data.decode("ascii")
207
+ except UnicodeDecodeError:
208
+ return None
209
+ if not text.endswith("\n"):
210
+ return None
211
+ parts = text[:-1].split(" ")
212
+ if (len(parts) != 4 or parts[0] != _WAITER_MAGIC
213
+ or parts[1] != _WAITER_VERSION
214
+ or not parts[2].isdigit() or not parts[3].isdigit()):
215
+ return None
216
+ return int(parts[3])
217
+
218
+
219
+ class ClaudeWaiter:
220
+ """The one process-owned Claude waiter (#1055 spec 4.1)."""
221
+
222
+ def __init__(self, fd: int):
223
+ self._fd: "int | None" = fd
224
+
225
+ @classmethod
226
+ def register(cls, app_dir, *, deadline_ns: int) -> "ClaudeWaiter | None":
227
+ """One non-blocking exclusive attempt; None when another waiter or a
228
+ drain probe holds the file at that instant (never retried)."""
229
+ try:
230
+ fd = os.open(_waiter_path(app_dir), os.O_RDWR | os.O_CREAT, 0o600)
231
+ except OSError:
232
+ return None
233
+ try:
234
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
235
+ except OSError:
236
+ os.close(fd)
237
+ return None
238
+ waiter = cls(fd)
239
+ try:
240
+ waiter._write(deadline_ns)
241
+ except OSError:
242
+ waiter.release()
243
+ return None
244
+ return waiter
245
+
246
+ def _write(self, expiry_ns: int) -> None:
247
+ record = (f"{_WAITER_MAGIC} {_WAITER_VERSION} {os.getpid()} "
248
+ f"{int(expiry_ns)}\n").encode("ascii")
249
+ os.pwrite(self._fd, record, 0)
250
+ os.ftruncate(self._fd, len(record))
251
+
252
+ def mark_holding(self) -> None:
253
+ """Rewrite the record once the hook holds ``cache.db.lock``: fresh for
254
+ the hold allowance from now. A failed write leaves the registration
255
+ record, which only ends the yield sooner."""
256
+ if self._fd is None:
257
+ return
258
+ try:
259
+ self._write(int(_monotonic_ns())
260
+ + round(CLAUDE_WAITER_HOLD_ALLOWANCE_SECONDS * 1e9))
261
+ except OSError:
262
+ pass
263
+
264
+ def release(self) -> None:
265
+ """Truncate the record, unlock, close. Idempotent; never raises
266
+ ``OSError``."""
267
+ fd, self._fd = self._fd, None
268
+ if fd is None:
269
+ return
270
+ for step in (lambda: os.ftruncate(fd, 0),
271
+ lambda: fcntl.flock(fd, fcntl.LOCK_UN),
272
+ lambda: os.close(fd)):
273
+ try:
274
+ step()
275
+ except OSError:
276
+ pass
277
+
278
+
279
+ def claude_waiter_fresh(app_dir) -> bool:
280
+ """The drain-side probe: never creates the file, holds no lock past the
281
+ call, and stamps the observation hint when the waiter is fresh."""
282
+ try:
283
+ fd = os.open(_waiter_path(app_dir), os.O_RDONLY)
284
+ except OSError:
285
+ return False
286
+ try:
287
+ try:
288
+ fcntl.flock(fd, fcntl.LOCK_SH | fcntl.LOCK_NB)
289
+ except BlockingIOError:
290
+ pass
291
+ except OSError:
292
+ return False
293
+ else:
294
+ return False # no waiter; close releases the probe
295
+ try:
296
+ data = os.pread(fd, _WAITER_RECORD_MAX, 0)
297
+ except OSError:
298
+ return False
299
+ finally:
300
+ os.close(fd)
301
+ expiry = _parse_waiter_expiry(data)
302
+ if expiry is None:
303
+ return False
304
+ now = int(_monotonic_ns())
305
+ if not (now < expiry and expiry - now <= _waiter_bound_ns()):
306
+ return False
307
+ _claude_waiter_observed[_hint_key(app_dir)] = now
308
+ return True
309
+
310
+
311
+ def claude_waiter_recently_observed(app_dir) -> bool:
312
+ """True when this process found a fresh waiter less than
313
+ ``CLAUDE_WAITER_RECENT_SECONDS`` ago (a sleep-interval hint only)."""
314
+ stamp = _claude_waiter_observed.get(_hint_key(app_dir))
315
+ if stamp is None:
316
+ return False
317
+ age = int(_monotonic_ns()) - stamp
318
+ return 0 <= age < round(CLAUDE_WAITER_RECENT_SECONDS * 1e9)
319
+
320
+
321
+ def lock_busy(path) -> bool:
322
+ """A shared non-blocking probe on its own descriptor, closed at once.
323
+ A missing file or any non-contention error is no evidence (False)."""
324
+ try:
325
+ fd = os.open(path, os.O_RDONLY)
326
+ except OSError:
327
+ return False
328
+ try:
329
+ fcntl.flock(fd, fcntl.LOCK_SH | fcntl.LOCK_NB)
330
+ except BlockingIOError:
331
+ return True
332
+ except OSError:
333
+ return False
334
+ finally:
335
+ os.close(fd)
336
+ return False
337
+
338
+
130
339
  class DrainGate:
131
340
  """The one global, non-blocking drain-admission flock."""
132
341
 
@@ -207,8 +416,17 @@ def finish(admission: Admission,
207
416
 
208
417
  def wait_until_no_fresh_requests(
209
418
  app_dir, *, should_continue: Optional[Callable[[], bool]] = None) -> None:
210
- """Sleep in bounded steps until no request is fresh (requests expire)."""
211
- while fresh_requests(app_dir):
419
+ """Sleep in bounded steps until nothing is fresh (requests expire; the
420
+ Claude waiter's record bounds it). The set is re-classified after every
421
+ sleep: the Claude sentinel alone takes ``SHORT_POLL_SECONDS``, any set
422
+ holding a Codex request ``POLL_SECONDS``."""
423
+ while True:
424
+ fresh = fresh_requests(app_dir)
425
+ if not fresh:
426
+ return
212
427
  if should_continue is not None and not should_continue():
213
428
  return
214
- _sleep(POLL_SECONDS)
429
+ if fresh == [CLAUDE_WAITER_SENTINEL]:
430
+ _sleep(SHORT_POLL_SECONDS)
431
+ else:
432
+ _sleep(POLL_SECONDS)
@@ -22,6 +22,7 @@ implementation of the rule without an import cycle.
22
22
  from __future__ import annotations
23
23
 
24
24
  import dataclasses
25
+ import json
25
26
  import sqlite3
26
27
  import threading
27
28
  from collections import OrderedDict
@@ -102,6 +103,44 @@ def decode_codex_project_metadata(
102
103
  )
103
104
 
104
105
 
106
+ #: The reader header's branch statuses (#1037). ``not_recorded`` is a readable
107
+ #: record that names no branch; ``unreadable`` is metadata the store holds but
108
+ #: cannot read (or no thread row at all).
109
+ GIT_BRANCH_RECORDED = "recorded"
110
+ GIT_BRANCH_NOT_RECORDED = "not_recorded"
111
+ GIT_BRANCH_UNREADABLE = "unreadable"
112
+
113
+
114
+ def derive_codex_git_branch(
115
+ decoded: "DecodedCodexProjectMetadata | None",
116
+ ) -> "tuple[str | None, str]":
117
+ """``(git_branch, git_branch_status)`` for one thread row's OWN ``git_json``
118
+ (#1037), first match wins: no thread row (``None``) or undecodable
119
+ ``git_json`` bytes → unreadable; NULL or empty → not recorded; invalid JSON
120
+ or not an object → unreadable; a string ``branch`` with a non-empty
121
+ ``strip()`` → that string, unmodified; anything else → not recorded.
122
+
123
+ ``cwd`` is never consulted, and neither is a parent's or root's metadata:
124
+ the project attribution exclusion governs the project label only, so a
125
+ corrupt ``cwd`` beside a readable branch still shows the branch (R-P1).
126
+ """
127
+ if decoded is None or "git_json" in decoded.undecodable_fields:
128
+ return None, GIT_BRANCH_UNREADABLE
129
+ raw = decoded.git_json
130
+ if raw is None or raw == "":
131
+ return None, GIT_BRANCH_NOT_RECORDED
132
+ try:
133
+ value = json.loads(raw)
134
+ except (ValueError, RecursionError):
135
+ return None, GIT_BRANCH_UNREADABLE
136
+ if not isinstance(value, dict):
137
+ return None, GIT_BRANCH_UNREADABLE
138
+ branch = value.get("branch")
139
+ if isinstance(branch, str) and branch.strip():
140
+ return branch, GIT_BRANCH_RECORDED
141
+ return None, GIT_BRANCH_NOT_RECORDED
142
+
143
+
105
144
  def _absent(metadata: "DecodedCodexProjectMetadata | None", field: str) -> bool:
106
145
  """Whether the resolver would move past this field.
107
146
 
@@ -120,11 +120,15 @@ def _dash_encode(path: str) -> str:
120
120
  def build_anon_plan(*, project_roots, home_dirs, usernames) -> AnonPlan:
121
121
  """Build an :class:`AnonPlan` from injected identity sources.
122
122
 
123
- ``project_roots`` maps root path -> label. Numbering is deterministic,
124
- lexical by root path (``project-1..N``), so CLI and HTTP builds from the same
125
- DB produce identical plans (byte-parity depends on this). Duplicate basenames
126
- map the bare label to the LOWEST-numbered of their ``project-N`` targets
127
- (``setdefault`` over the sorted roots).
123
+ ``project_roots`` maps root path -> label, or -> a collection of labels
124
+ (#972 O6: a relabeled Codex root keeps both its repository label and its
125
+ folder name). Numbering is deterministic, lexical by root path
126
+ (``project-1..N``), so CLI and HTTP builds from the same DB produce
127
+ identical plans (byte-parity depends on this). Every label of a root
128
+ targets that root's ``project-N``; a bare label several roots carry maps to
129
+ the LOWEST-numbered of their targets (``setdefault`` over the sorted
130
+ roots), so an added label can move a colliding word to a lower-numbered
131
+ root. String-only inputs build exactly the plan they always built.
128
132
  """
129
133
  tokens: dict = {} # (text, bounded) -> replacement
130
134
  label_target: dict = {} # label -> project-N (lowest-N wins)
@@ -134,9 +138,12 @@ def build_anon_plan(*, project_roots, home_dirs, usernames) -> AnonPlan:
134
138
  rep = f"project-{n}"
135
139
  tokens[(root, False)] = rep
136
140
  tokens[(_dash_encode(root), False)] = f"-{rep}"
137
- label = project_roots[root]
138
- if label:
139
- label_target.setdefault(label, rep) # lowest-N claims the label
141
+ labels = project_roots[root]
142
+ if labels is None or isinstance(labels, str):
143
+ labels = (labels,)
144
+ # A None or empty label (alone or inside a collection) names nothing.
145
+ for label in sorted({label for label in labels if label}):
146
+ label_target.setdefault(label, rep) # lowest-N claims it
140
147
  for label, rep in label_target.items():
141
148
  tokens[(label, True)] = rep
142
149
  for h in home_dirs:
@@ -445,6 +445,10 @@ def _claude_detail(
445
445
  "status": "ok",
446
446
  "conversation_key": conversation_key,
447
447
  "title": res["title"],
448
+ # #1037 twin: the reader header's project and branch slots, as the
449
+ # native Claude detail carries them (no branch status for Claude).
450
+ "project_label": res.get("project_label"),
451
+ "git_branch": res.get("git_branch"),
448
452
  "items": neutral_items,
449
453
  "page": page,
450
454
  "subagent_meta": _map_claude_subagent_meta(
@@ -621,6 +625,9 @@ def _claude_search(
621
625
  "item_key": _claude_item_key(sid, uuid) if (sid and uuid) else None,
622
626
  "title": h.get("title"),
623
627
  "snippet": h.get("snippet"),
628
+ # #1036: the structured excerpt the viewer renders (the qualified
629
+ # All-providers rail has nothing else that marks only real matches).
630
+ "snippet_parts": h.get("snippet_parts", []),
624
631
  "badges": list(h.get("match_kinds") or []),
625
632
  "last_activity_utc": last_act,
626
633
  "project_label": project_label,