fix(halacha): authoritative rate-limit detection + early-morning catch-up window (supervisor)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
הדריינר רץ בלילה 13→14.6 אך חילץ 0 הלכות: מכסת-המנוי של claude.ai אזלה
(`api_error_status:429 "You've hit your session limit · resets 2:30am UTC"`,
`total_cost_usd:0`), וה-reset (5:30 IDT) נחת מעט אחרי סגירת החלון (05:00 IDT).
המתזמר סיווג זאת שגוי כ-"hung" ועשה restart-storm כל 15 דק' — כי `scan_rate_limit`
קורא רק 120 שורות-זנב, וה-429 (שורה 8273/9170) נקבר תחת ~900 שורות teardown שה-churn
שלו עצמו ייצר. בנוסף "hold" לא עצר את הדריינר → המשך הלמת-429 ובזבוז המכסה.
Fix A — זיהוי rate-limit עמיד:
• `quota_exhausted()` חדש: מקור-האמת הוא endpoint-המכסה (`subscription_usage`,
אותו util שה-UI מציג) — durable, לא תלוי בעומק-זנב-הלוג. log-scrape רק כ-fallback.
• בזמן מוגבל עוצר דריינר online (`hold-stopped`) כדי לא להלום 429; מצית-מחדש
כשהמכסה חוזרת (exit מיידי כש-endpoint <100%, או probe `claude -p` אם endpoint למטה).
Fix B — חלון catch-up בוקר [05:00–07:00 IDT):
• נפתח רק לניקוי backlog שנותר כשהמכסה חזרה (מגודר: לא-מוגבל + תור≠ריק) כדי
שהמכסה המשוחררת לא תתבזבז עד הלילה הבא. הקצה המורחב מועבר לדריינר (window self-guard).
נתונים בטוחים — תיקים נשארו 'processing' for retry, שום הלכה לא אבדה.
13 unit-tests עוברים (parse endpoint, gating של catch-band, win extension); `status` חי OK.
Invariants: מקיים G1 (תיקון-במקור: זיהוי ממקור-מכסה סמכותי, לא מתסמין-לוג),
G2 (אותו endpoint+מנגנון-חלון קיימים — בלי מסלול מקביל), INV-G3/X16 (לא נוגע
ב-checkpointing הדטרמיניסטי). G12 לא רלוונטי (host-side pm2, בלי Paperclip).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -112,7 +112,7 @@
|
||||
| `ingest_incoming_batch.py` | python | קליטת batch של החלטות ועדת ערר מ-`data/precedents/incoming/` דרך המסלול הקנוני (`ingest_internal_decision`) + חילוץ מטא-דאטה לכל תיק (המסלול הפנימי לא מתזמן metadata — INV-ING3). רצף (לא מקבילי, להימנע מעומס CLI). רשימת `DECISIONS` נערכת ידנית לכל batch. config מ-`~/.env`. תומך תהליך [[project_precedent_incoming_workflow]]. | ידני, per-batch (חלופה ל-MCP `internal_decision_upload` כש-batch גדול) |
|
||||
| `drain_halacha_queue.py` | python | ריקון תור חילוץ ההלכות (`process_pending_extractions kind='halacha'`) ב-batches של 4 עד שהתור ריק (2 סבבים ריקים). **רץ רק בחלון-לילה 23:00–05:00 שעון ישראל** (`_in_window`, zoneinfo DST-safe — המכונה UTC); מחוץ לחלון `===SKIP===`, ונעצר `===STOP===` כשהחלון נסגר (השאר ממשיך בלילה הבא, FIFO+checkpoint). env: `HALACHA_DRAIN_WINDOW_START`/`_END`/`HALACHA_DRAIN_TZ`. **kill-switch `/operations`:** בודק `is_drain_disabled` בעלייה **וגם בתחילת כל סבב** — כיבוי באמצע-ריצה עוצר את הלולאה בגבול-הסבב הבא (התהליך עצמו נהרג מיד דרך ה-UI-toggle/סופרוייזר). חילוץ-הלכות נשאר על claude_session (לא Gemini). self-heal ל-orphaned `processing`. ההלכות נוחתות `pending_review` (שער-יו"ר). **חילוץ תיק-בודד שהיו"ר מבקש רץ מיד דרך ה-CEO (`precedent_extract_halachot`) ואינו מגודר כאן.** | דרך `legal-halacha-drain.config.cjs` (pm2 cron) / ידני |
|
||||
| `legal-halacha-drain.config.cjs` | pm2/js | **תזמון חלון-לילה של `drain_halacha_queue.py`** (cron UTC `10 20,21,22,23,0,1,2,3 * * *` = superset שמכסה את 23:00–05:00 ישראל בקיץ ובחורף; הסקריפט גוזם לחלון המדויק ב-zoneinfo). דקת-הצתה `:10` (לא `:00`) כדי לא לחלוק דקה עם metadata-drain (`:00`) או supervisor (`:05`) — מונע deadlock של DDL-המיגרציה כששני דריינים עולים יחד. `HALACHA_DRAIN_CRON` לעקיפה. ירייה כל שעה גם מחדשת one-shot שמת באמצע (advisory-lock הופך חפיפה לבטוחה). דורש claude CLI. התקנה: `pm2 start scripts/legal-halacha-drain.config.cjs && pm2 save`. | pm2 cron (host-side) |
|
||||
| `halacha_drain_supervisor.py` | python | **מנהל-בריאות קבוע ל-`legal-halacha-drain`** (אפס צריכת-Claude — קורא DB/לוגים/pm2 ומצית את הדריינר הקיים). טיק יחיד: **מכבד `is_drain_disabled` בעדיפות עליונה — אם כבוי ב-/operations עוצר את הדריינר ולא מצית** · מצית כשבטל+תור≠ריק · restart ל-run תקוע (liveness לפי checkpoints-per-chunk, **לא** mtime-לוג שמתעדכן רק בסיום תיק ~10 דק') · backoff ב-rate-limit (429 + parse איפוס, מגודר-טריות; `cost=0`=מנוי) — **אך לא ממתין בעיוורון לשעה המדווחת: `quota_available()` קורא קודם את `subscription_usage()` (endpoint לא-מתועד `GET /api/oauth/usage`, token מ-`~/.claude/.credentials.json` + UA `claude-code/*` חובה אחרת 429) — אותו אחוז-ניצול 5-שעות/שבועי/שבועי-Sonnet שה-UI מציג — ומתחדש כשכל החלונות <100% (Sonnet הוא הקאפ הפר-מודלי המאוכלס; Opus null, וה-seven_day הכללי הוא ה-backstop ל-Opus). fallback לבדיקת `claude -p` זעירה אם ה-endpoint לא זמין. `status` מדפיס את האחוזים** · מאמת ש-staging מתחייב. **BURST** (חלון "רוץ ברצף עכשיו" ידני): מקור-אמת = `drain_controls.burst_until` ב-DB — אותו ערך ש-/operations קורא/כותב (G1 מקור-יחיד, G2 בלי מסלול מקביל); בעתיד→חלון מורם, אחרת חלון-לילה 23-05; פג-תוקף אוטומטי במועד. תת-פקודות: `tick` (ברירת-מחדל), `burst-on [--until]`, `burst-off`, `status`. | דרך `legal-halacha-supervisor.config.cjs` (pm2 cron) / ידני / כפתור /operations |
|
||||
| `halacha_drain_supervisor.py` | python | **מנהל-בריאות קבוע ל-`legal-halacha-drain`** (אפס צריכת-Claude — קורא DB/לוגים/pm2 ומצית את הדריינר הקיים). טיק יחיד: **מכבד `is_drain_disabled` בעדיפות עליונה — אם כבוי ב-/operations עוצר את הדריינר ולא מצית** · מצית כשבטל+תור≠ריק · restart ל-run תקוע (liveness לפי checkpoints-per-chunk, **לא** mtime-לוג שמתעדכן רק בסיום תיק ~10 דק') · **זיהוי rate-limit: ה-PRIMARY הוא `quota_exhausted()` הסמכותי (קורא `subscription_usage()` — endpoint לא-מתועד `GET /api/oauth/usage`, token מ-`~/.claude/.credentials.json` + UA `claude-code/*` חובה אחרת 429 — אותו אחוז-ניצול 5-שעות/שבועי/שבועי-Sonnet שה-UI מציג, חלון≥100%=מוצה), durable ואינו תלוי בעומק-זנב-הלוג; `scan_rate_limit` (429 + parse איפוס, מגודר-טריות) רק כ-fallback כש-endpoint לא-זמין — תיקון הבאג שבו 429 שגלל מתחת ל-churn של ה-restart נקרא כ"hung" וגרר restart-storm שבזבז מכסה.** בזמן מוגבל **עוצר את הדריינר** (`hold-stopped`) כדי לא להלום 429, ומצית-מחדש כשהמכסה חוזרת (כל החלונות <100%; fallback `claude -p` זעיר אם ה-endpoint לא זמין). `status` מדפיס את האחוזים · **חלון catch-up בוקר `[05:00–07:00 IDT)` (`CATCHUP_END`) שנפתח רק לניקוי backlog שנותר כשהמכסה חזרה — איפוס-5-שעות מאוחר נוחת לרוב מעט אחרי 05:00; מגודר ב-(לא-מוגבל)+(תור≠ריק), והקצה המורחב מועבר לדריינר כך ש-window-self-guard שלו מקבל את סבבי-ה-catch-up** · מאמת ש-staging מתחייב. **BURST** (חלון "רוץ ברצף עכשיו" ידני): מקור-אמת = `drain_controls.burst_until` ב-DB — אותו ערך ש-/operations קורא/כותב (G1 מקור-יחיד, G2 בלי מסלול מקביל); בעתיד→חלון מורם, אחרת חלון-לילה 23-05; פג-תוקף אוטומטי במועד. תת-פקודות: `tick` (ברירת-מחדל), `burst-on [--until]`, `burst-off`, `status`. | דרך `legal-halacha-supervisor.config.cjs` (pm2 cron) / ידני / כפתור /operations |
|
||||
| `legal-halacha-supervisor.config.cjs` | pm2/js | **תזמון כל 15 דק' של `halacha_drain_supervisor.py`** (cron `5-59/15 * * * *` = `:05,:20,:35,:50`, `HALACHA_SUPERVISOR_CRON` לעקיפה; דקת-הצתה `:05` כדי לא לחלוק דקה עם metadata-drain `:00` או halacha-drain `:10` — מונע deadlock של DDL-המיגרציה). `autorestart:false` (one-shot per tick). מצב-state ב-`~/halacha-drain-monitor/` (מחוץ ל-repo). התקנה: `pm2 start scripts/legal-halacha-supervisor.config.cjs && pm2 save`. | pm2 cron (host-side) |
|
||||
| `ingest_digests_batch.py` | python | קליטת batch של יומוני "כל יום" מ-`data/digests/incoming/` דרך המסלול העצמאי של קורפוס-הגילוי (`digest_library.ingest_digest`) — חילוץ-LLM (תג-מושג, כותרת-הלכה, מראה-מקום, שני-תאריכים), embedding יחיד, ו-autolink לפסק המקורי (X12/INV-DIG3). רצף (לא מקבילי). מזהה-יומון+תאריך נגזרים משם-הקובץ; העלון החודשי מדולג. **לא מעביר קבצים** — ה-DB (content_hash) הוא מקור-האמת היחיד; הרצה חוזרת מדלגת על קיימים (`exists`). config מ-`~/.env`. | ידני, per-batch (חלופה ל-MCP `digest_upload`) |
|
||||
| `drain_digests.py` | python | ריקון תור ההעשרה של יומונים (X12): מעבד כל digest בסטטוס `pending` דרך `digest_library.enrich_digest` (חילוץ-LLM Sonnet + embedding + autolink). מקבילי (CONCURRENCY=3, env-tunable), idempotent. מוסיף `~/.local/bin` ל-PATH כדי שה-claude CLI יימצא תחת cron. בודק דגל `drain_controls('legal-digest-drain')` ב-startup → no-op כשכבוי מ-/operations. | דרך `legal-digest-drain.config.cjs` (pm2 cron) + ידני אחרי backfill. חלופת-MCP: `digest_process_pending` |
|
||||
|
||||
@@ -22,10 +22,17 @@ log tails →
|
||||
• re-triggers the one-shot drain when idle and the queue is non-empty
|
||||
• restarts a HUNG run (online but no new chunk-checkpoint for > 25 min — the
|
||||
REAL liveness signal; the out-log only updates when a whole CASE finishes)
|
||||
• backs off on rate-limit (claude_session 429) until the CLI's parsed reset;
|
||||
a 429 only counts when the log is FRESH (an hours-old 429 is historical)
|
||||
• backs off on rate-limit until quota resets — PRIMARY signal is the authoritative
|
||||
OAuth usage endpoint (durable; the same util the Claude Code UI shows), with the
|
||||
log 429 only as a fallback when that endpoint is unreachable. While limited it
|
||||
STOPS the drain (no 429-hammering) and re-ignites once quota is back.
|
||||
• verifies crash-safe per-chunk staging is committing (nothing lost)
|
||||
|
||||
The night window is 23:00–05:00 IDT, with a bounded early-morning CATCH-UP band
|
||||
[05:00–07:00 IDT) that opens ONLY to drain a leftover backlog once quota is back —
|
||||
a late 5-hour reset commonly lands just past 05:00, and this keeps the freed quota
|
||||
from idling until the next night (gated on quota + non-empty queue).
|
||||
|
||||
TWO MODES (never self-stops):
|
||||
• burst — while burst_until is set and in the future: window LIFTED
|
||||
(HALACHA_DRAIN_WINDOW_START==END==0), drain runs all day.
|
||||
@@ -63,6 +70,7 @@ STUCK_SILENCE_SEC = 1500 # 25 min with no new chunk-checkpoint while online
|
||||
WEEKLY_GAP_HOURS = 6 # reset further than this → treat as weekly, not 5h
|
||||
IDT = timezone(timedelta(hours=3)) # Israel summer time (IDT, UTC+3) — display only
|
||||
NIGHT_START, NIGHT_END = 23, 5 # the drain's normal window (IDT hours)
|
||||
CATCHUP_END = 7 # soft window end (IDT) for early-morning catch-up — see fix B
|
||||
|
||||
|
||||
def _now_utc():
|
||||
@@ -163,6 +171,34 @@ def quota_available() -> bool:
|
||||
return "OK" in out
|
||||
|
||||
|
||||
def quota_exhausted():
|
||||
"""Authoritative rate-limit signal from the OAuth usage endpoint — the SAME
|
||||
source the Claude Code UI (and quota_available) reads. Durable, unlike
|
||||
scan_rate_limit's log tail, which loses the 429 the moment it scrolls out from
|
||||
under the supervisor's own restart-churn (the bug that let a 5-hour exhaustion
|
||||
masquerade as 'hung' all night).
|
||||
|
||||
Returns (exhausted: bool, earliest_reset_utc: datetime|None), or None when the
|
||||
endpoint is unreachable (caller falls back to the log scrape). A window counts
|
||||
as exhausting the drain at >=100% utilization — same windows quota_available
|
||||
gates on (5-hour, weekly all-models, weekly-Sonnet)."""
|
||||
usage = subscription_usage()
|
||||
if usage is None:
|
||||
return None
|
||||
exhausted, resets = False, []
|
||||
for w in ("five_hour", "seven_day", "seven_day_sonnet"):
|
||||
info = usage.get(w) or {}
|
||||
if (info.get("utilization") or 0) >= 100:
|
||||
exhausted = True
|
||||
r = info.get("resets_at")
|
||||
if r:
|
||||
try:
|
||||
resets.append(datetime.fromisoformat(r).astimezone(timezone.utc))
|
||||
except Exception:
|
||||
pass
|
||||
return exhausted, (min(resets) if resets else None)
|
||||
|
||||
|
||||
# ── DB access (via the repo venv; the module self-configures) ────────────────
|
||||
def _venv_py(code: str, timeout: int = 120) -> str:
|
||||
r = subprocess.run([VENV_PY, "-c", code], capture_output=True, text=True,
|
||||
@@ -375,10 +411,12 @@ def tick():
|
||||
burst = bool(burst_until and now < burst_until)
|
||||
|
||||
idt_hour = now.astimezone(IDT).hour
|
||||
night_open = (idt_hour >= NIGHT_START) or (idt_hour < NIGHT_END)
|
||||
window_open = burst or night_open
|
||||
win = (0, 0) if burst else (NIGHT_START, NIGHT_END)
|
||||
core_open = (idt_hour >= NIGHT_START) or (idt_hour < NIGHT_END)
|
||||
in_catchup_band = (NIGHT_END <= idt_hour < CATCHUP_END) # [05:00,07:00) IDT
|
||||
phase = "burst" if burst else "night"
|
||||
# window_open / win are finalized AFTER the cooldown + backlog are known — the
|
||||
# early-morning catch-up band (fix B) is gated on quota being back and a
|
||||
# leftover backlog, so it can't be decided here yet.
|
||||
|
||||
sc = snap["status_counts"]
|
||||
pending = int(sc.get("pending", 0))
|
||||
@@ -397,33 +435,53 @@ def tick():
|
||||
d_ck = ck - prev.get("checkpointed_chunks", ck)
|
||||
d_done = done - prev.get("done", done)
|
||||
|
||||
# cooldown — fresh-gated; honor a stored future reset
|
||||
# cooldown — the authoritative OAuth usage endpoint is PRIMARY (durable); the
|
||||
# log scrape is the fallback only when the endpoint is unreachable. This is the
|
||||
# core of fix A: log-scraping alone went blind once a 429 scrolled out from
|
||||
# under the supervisor's own restart-churn, so an exhausted 5-hour window read
|
||||
# as 'hung' and got hammered with restarts. The endpoint can't scroll away.
|
||||
fresh = (age is not None and age < 1800)
|
||||
rl_active = bool(rl_recent and fresh)
|
||||
log_rl = bool(rl_recent and fresh)
|
||||
auth = quota_exhausted() # (exhausted, reset_utc) | None if endpoint down
|
||||
auth_says_ok = (auth is not None and not auth[0])
|
||||
cd_dt = None
|
||||
if rl_active and reset_dt:
|
||||
if auth is not None and auth[0]: # authoritative: a window is exhausted
|
||||
cd_dt = auth[1] or reset_dt # prefer endpoint reset; fall back to parsed
|
||||
elif log_rl and not auth_says_ok: # endpoint down/silent → trust a fresh 429
|
||||
cd_dt = reset_dt
|
||||
elif prev.get("cooldown_until"):
|
||||
if cd_dt is None and prev.get("cooldown_until"): # persist a stored future reset
|
||||
try:
|
||||
cd_dt = datetime.fromisoformat(prev["cooldown_until"])
|
||||
except Exception:
|
||||
cd_dt = None
|
||||
in_cooldown = bool(cd_dt and now < cd_dt)
|
||||
# Don't trust the reported reset time — re-probe. claude.ai usually frees up
|
||||
# quota EARLIER than the 429 message claims, and the old code then sat idle
|
||||
# until that (conservative) timestamp. When we'd otherwise hold, a tiny live
|
||||
# probe lets us resume the instant quota is actually back (≤ one tick), no
|
||||
# manual kick. Runs at most once per tick and only while we think we're
|
||||
# limited, so the cost is negligible.
|
||||
if in_cooldown and quota_available():
|
||||
# Exit cooldown the instant quota is actually back — claude.ai usually frees up
|
||||
# EARLIER than the reported reset. Authoritative all-clear is decisive; when the
|
||||
# endpoint is down, fall back to the tiny live CLI probe (old behavior).
|
||||
if in_cooldown and (auth_says_ok or (auth is None and quota_available())):
|
||||
notes.append(
|
||||
f"בדיקת-מכסה הצליחה — המכסה חזרה לפני האיפוס המדווח "
|
||||
f"({cd_dt.astimezone(IDT):%H:%M IDT}); מתחדש מיד.")
|
||||
cd_dt = None
|
||||
in_cooldown = False
|
||||
f"בדיקת-מכסה: המכסה זמינה — מתחדש מיד "
|
||||
f"(לפני האיפוס המדווח {cd_dt.astimezone(IDT):%H:%M IDT}).")
|
||||
cd_dt, in_cooldown = None, False
|
||||
cooldown_until = cd_dt.isoformat() if cd_dt else None
|
||||
weekly = bool(cd_dt and (cd_dt - now) > timedelta(hours=WEEKLY_GAP_HOURS))
|
||||
|
||||
# fix B — early-morning catch-up window. The fixed night window ends 05:00 IDT,
|
||||
# but the claude.ai 5-hour quota reset drifts and on a rate-limited night
|
||||
# commonly lands just after it; without this the freed quota idles until the
|
||||
# next night. Open a bounded catch-up band [05:00–07:00 IDT) to clear a LEFTOVER
|
||||
# backlog once quota is back. Gated on (not in_cooldown) → never opens while
|
||||
# still limited; gated on a non-empty queue → a clean early morning never drags
|
||||
# the drain into the day. The extended end is also passed to the drain so its
|
||||
# own window self-guard accepts the catch-up rounds.
|
||||
backlog = (pending > 0 or processing > 0)
|
||||
catchup = bool(in_catchup_band and backlog and not in_cooldown)
|
||||
window_open = burst or core_open or catchup
|
||||
win = (0, 0) if burst else (NIGHT_START, CATCHUP_END if catchup else NIGHT_END)
|
||||
if catchup and not core_open:
|
||||
notes.append(f"catch-up בוקר ({NIGHT_END}:00–0{CATCHUP_END}:00) — מנצל מכסה "
|
||||
f"משוחררת לסיום {pending} בתור (איפוס מאוחר אחרי החלון).")
|
||||
|
||||
# progress-based liveness (chunk checkpoints, NOT log mtime)
|
||||
if ck > prev.get("checkpointed_chunks", ck):
|
||||
last_progress_at = now
|
||||
@@ -444,8 +502,18 @@ def tick():
|
||||
notes.append("התור ריק — אין מה לחלץ.")
|
||||
elif in_cooldown:
|
||||
mode = "weekly_exhausted" if weekly else "ratelimited"
|
||||
action = "hold"
|
||||
notes.append(f"rate-limit פעיל; איפוס ~{cd_dt.astimezone(IDT):%H:%M IDT}.")
|
||||
# Stop a running drain while limited — otherwise it keeps spawning Opus
|
||||
# calls that 429 on every chunk, burning the very quota we're waiting on
|
||||
# (and burying the 429 signal under teardown noise). It re-ignites via the
|
||||
# normal trigger path once cooldown clears.
|
||||
if status == "online":
|
||||
stop_drain()
|
||||
action = "hold-stopped"
|
||||
notes.append(f"rate-limit פעיל — הדריינר נעצר כדי לא לבזבז מכסה על 429; "
|
||||
f"איפוס ~{cd_dt.astimezone(IDT):%H:%M IDT}.")
|
||||
else:
|
||||
action = "hold"
|
||||
notes.append(f"rate-limit פעיל; איפוס ~{cd_dt.astimezone(IDT):%H:%M IDT}.")
|
||||
elif hung:
|
||||
mode = "hung"
|
||||
ok, detail = trigger_drain(*win)
|
||||
|
||||
Reference in New Issue
Block a user