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.
- package/CHANGELOG.md +30 -0
- package/README.md +7 -3
- package/bin/_cctally_cache.py +2783 -175
- package/bin/_cctally_completion.py +106 -33
- package/bin/_cctally_core.py +69 -4
- package/bin/_cctally_dashboard.py +188 -13
- package/bin/_cctally_dashboard_conversation.py +5 -1
- package/bin/_cctally_dashboard_envelope.py +50 -19
- package/bin/_cctally_dashboard_sources.py +23 -1
- package/bin/_cctally_db.py +193 -153
- package/bin/_cctally_diagnosis_sources.py +63 -7
- package/bin/_cctally_doctor.py +865 -93
- package/bin/_cctally_five_hour.py +67 -50
- package/bin/_cctally_forecast.py +35 -13
- package/bin/_cctally_journal.py +186 -18
- package/bin/_cctally_milestone_history.py +859 -62
- package/bin/_cctally_oauth_samples.py +286 -15
- package/bin/_cctally_parser.py +2 -2
- package/bin/_cctally_quota.py +104 -12
- package/bin/_cctally_record.py +153 -29
- package/bin/_cctally_rederive.py +608 -68
- package/bin/_cctally_statusline.py +108 -81
- package/bin/_cctally_store.py +179 -38
- package/bin/_cctally_tui.py +892 -181
- package/bin/_cctally_update.py +32 -13
- package/bin/_lib_codex_conversation_query.py +52 -14
- package/bin/_lib_codex_drain_admission.py +224 -6
- package/bin/_lib_codex_metadata.py +39 -0
- package/bin/_lib_conversation_anon.py +15 -8
- package/bin/_lib_conversation_dispatch.py +7 -0
- package/bin/_lib_conversation_query.py +289 -70
- package/bin/_lib_dashboard_sources.py +37 -1
- package/bin/_lib_detector_access_registry.py +497 -26
- package/bin/_lib_doctor.py +85 -20
- package/bin/_lib_doctor_scan_index.py +321 -0
- package/bin/_lib_ingest_frontier.py +20 -1
- package/bin/_lib_oauth_samples.py +23 -7
- package/bin/_lib_pricing.py +146 -16
- package/bin/_lib_quota.py +19 -0
- package/bin/_lib_rederive.py +11 -6
- package/bin/_lib_retained_records.py +516 -0
- package/bin/_lib_snapshot_cache.py +10 -0
- package/bin/_lib_stale_proof.py +272 -43
- package/bin/_lib_stats_content_revision.py +831 -0
- package/bin/_lib_write_io.py +11 -4
- package/bin/cctally +4 -1
- package/dashboard/static/assets/ConversationsView-D4Iv4ku0.js +72 -0
- package/dashboard/static/assets/{DoctorModal-B0DnU9d9.js → DoctorModal-D4QSq6uz.js} +1 -1
- package/dashboard/static/assets/ModalRoot-C9F5dhZi.js +1 -0
- package/dashboard/static/assets/{ProjectsDrillPanel-DSxXQSa-.js → ProjectsDrillPanel-WxAdRDMt.js} +1 -1
- package/dashboard/static/assets/{SourceDetailModal-BleDpQqG.js → SourceDetailModal-NyyEfXmc.js} +1 -1
- package/dashboard/static/assets/{UpdateModal-CMqaAkSK.js → UpdateModal-CFLq9pT1.js} +1 -1
- package/dashboard/static/assets/{index-BjQV-0-D.js → index-BKTWH9JE.js} +2 -2
- package/dashboard/static/assets/{outlineNavigation-CO-VWBm7.js → outlineNavigation-BnGUwCS3.js} +3 -3
- package/dashboard/static/dashboard.html +1 -1
- package/package.json +4 -1
- package/dashboard/static/assets/ConversationsView-oqNsWzWV.js +0 -72
- package/dashboard/static/assets/ModalRoot-CYua2wP_.js +0 -1
package/bin/_cctally_update.py
CHANGED
|
@@ -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 =
|
|
2972
|
+
doctor_payload = tui._tui_precompute_doctor_payload(
|
|
2960
2973
|
_now_utc(), self._runtime_bind,
|
|
2961
2974
|
)
|
|
2962
|
-
|
|
2963
|
-
|
|
2964
|
-
|
|
2965
|
-
|
|
2966
|
-
|
|
2967
|
-
|
|
2968
|
-
|
|
2969
|
-
|
|
2970
|
-
|
|
2971
|
-
|
|
2972
|
-
|
|
2973
|
-
|
|
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
|
|
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
|
-
|
|
2498
|
-
#
|
|
2499
|
-
#
|
|
2500
|
-
|
|
2501
|
-
"
|
|
2502
|
-
|
|
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"],
|
|
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":
|
|
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":
|
|
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.
|
|
23
|
-
|
|
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
|
|
211
|
-
|
|
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
|
-
|
|
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
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
(
|
|
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
|
-
|
|
138
|
-
if
|
|
139
|
-
|
|
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,
|