From 49efa94d606c7a18d526a853c46fca3d7ed1aba7 Mon Sep 17 00:00:00 2001 From: Chaim Date: Sun, 14 Jun 2026 10:02:35 +0000 Subject: [PATCH] fix(halacha): authoritative rate-limit detection + early-morning catch-up window (supervisor) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit הדריינר רץ בלילה 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) --- scripts/SCRIPTS.md | 2 +- scripts/halacha_drain_supervisor.py | 112 ++++++++++++++++++++++------ 2 files changed, 91 insertions(+), 23 deletions(-) diff --git a/scripts/SCRIPTS.md b/scripts/SCRIPTS.md index cec299a..d4b46d5 100644 --- a/scripts/SCRIPTS.md +++ b/scripts/SCRIPTS.md @@ -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` | diff --git a/scripts/halacha_drain_supervisor.py b/scripts/halacha_drain_supervisor.py index 02d8095..d8d0f3d 100644 --- a/scripts/halacha_drain_supervisor.py +++ b/scripts/halacha_drain_supervisor.py @@ -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)