Merge pull request 'fix(halacha): authoritative rate-limit detection + early-morning catch-up window (supervisor)' (#251) from worktree-halacha-supervisor-ratelimit into main
All checks were successful
G12 Leak-Guard / leak-guard (push) Successful in 4s
Lint — undefined names / undefined-names (push) Successful in 10s
Build & Deploy / build-and-deploy (push) Successful in 7s

This commit was merged in pull request #251.
This commit is contained in:
2026-06-14 10:03:24 +00:00
2 changed files with 91 additions and 23 deletions

View File

@@ -113,7 +113,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 גדול) | | `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:0005: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) / ידני | | `drain_halacha_queue.py` | python | ריקון תור חילוץ ההלכות (`process_pending_extractions kind='halacha'`) ב-batches של 4 עד שהתור ריק (2 סבבים ריקים). **רץ רק בחלון-לילה 23:0005: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:0005: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) | | `legal-halacha-drain.config.cjs` | pm2/js | **תזמון חלון-לילה של `drain_halacha_queue.py`** (cron UTC `10 20,21,22,23,0,1,2,3 * * *` = superset שמכסה את 23:0005: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:0007: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) | | `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`) | | `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` | | `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` |

View File

@@ -22,10 +22,17 @@ log tails →
• re-triggers the one-shot drain when idle and the queue is non-empty • 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 • 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) 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; • backs off on rate-limit until quota resets — PRIMARY signal is the authoritative
a 429 only counts when the log is FRESH (an hours-old 429 is historical) 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) • verifies crash-safe per-chunk staging is committing (nothing lost)
The night window is 23:0005:00 IDT, with a bounded early-morning CATCH-UP band
[05:0007: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): TWO MODES (never self-stops):
• burst — while burst_until is set and in the future: window LIFTED • burst — while burst_until is set and in the future: window LIFTED
(HALACHA_DRAIN_WINDOW_START==END==0), drain runs all day. (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 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 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) 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(): def _now_utc():
@@ -163,6 +171,34 @@ def quota_available() -> bool:
return "OK" in out 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) ──────────────── # ── DB access (via the repo venv; the module self-configures) ────────────────
def _venv_py(code: str, timeout: int = 120) -> str: def _venv_py(code: str, timeout: int = 120) -> str:
r = subprocess.run([VENV_PY, "-c", code], capture_output=True, text=True, 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) burst = bool(burst_until and now < burst_until)
idt_hour = now.astimezone(IDT).hour idt_hour = now.astimezone(IDT).hour
night_open = (idt_hour >= NIGHT_START) or (idt_hour < NIGHT_END) core_open = (idt_hour >= NIGHT_START) or (idt_hour < NIGHT_END)
window_open = burst or night_open in_catchup_band = (NIGHT_END <= idt_hour < CATCHUP_END) # [05:00,07:00) IDT
win = (0, 0) if burst else (NIGHT_START, NIGHT_END)
phase = "burst" if burst else "night" 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"] sc = snap["status_counts"]
pending = int(sc.get("pending", 0)) pending = int(sc.get("pending", 0))
@@ -397,33 +435,53 @@ def tick():
d_ck = ck - prev.get("checkpointed_chunks", ck) d_ck = ck - prev.get("checkpointed_chunks", ck)
d_done = done - prev.get("done", done) 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) 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 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 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: try:
cd_dt = datetime.fromisoformat(prev["cooldown_until"]) cd_dt = datetime.fromisoformat(prev["cooldown_until"])
except Exception: except Exception:
cd_dt = None cd_dt = None
in_cooldown = bool(cd_dt and now < cd_dt) in_cooldown = bool(cd_dt and now < cd_dt)
# Don't trust the reported reset time — re-probe. claude.ai usually frees up # Exit cooldown the instant quota is actually back — claude.ai usually frees up
# quota EARLIER than the 429 message claims, and the old code then sat idle # EARLIER than the reported reset. Authoritative all-clear is decisive; when the
# until that (conservative) timestamp. When we'd otherwise hold, a tiny live # endpoint is down, fall back to the tiny live CLI probe (old behavior).
# probe lets us resume the instant quota is actually back (≤ one tick), no if in_cooldown and (auth_says_ok or (auth is None and quota_available())):
# 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():
notes.append( notes.append(
f"בדיקת-מכסה הצליחה — המכסה חזרה לפני האיפוס המדווח " f"בדיקת-מכסה: המכסה זמינה — מתחדש מיד "
f"({cd_dt.astimezone(IDT):%H:%M IDT}); מתחדש מיד.") f"(לפני האיפוס המדווח {cd_dt.astimezone(IDT):%H:%M IDT}).")
cd_dt = None cd_dt, in_cooldown = None, False
in_cooldown = False
cooldown_until = cd_dt.isoformat() if cd_dt else None cooldown_until = cd_dt.isoformat() if cd_dt else None
weekly = bool(cd_dt and (cd_dt - now) > timedelta(hours=WEEKLY_GAP_HOURS)) 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:0007: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}:000{CATCHUP_END}:00) — מנצל מכסה "
f"משוחררת לסיום {pending} בתור (איפוס מאוחר אחרי החלון).")
# progress-based liveness (chunk checkpoints, NOT log mtime) # progress-based liveness (chunk checkpoints, NOT log mtime)
if ck > prev.get("checkpointed_chunks", ck): if ck > prev.get("checkpointed_chunks", ck):
last_progress_at = now last_progress_at = now
@@ -444,8 +502,18 @@ def tick():
notes.append("התור ריק — אין מה לחלץ.") notes.append("התור ריק — אין מה לחלץ.")
elif in_cooldown: elif in_cooldown:
mode = "weekly_exhausted" if weekly else "ratelimited" mode = "weekly_exhausted" if weekly else "ratelimited"
action = "hold" # Stop a running drain while limited — otherwise it keeps spawning Opus
notes.append(f"rate-limit פעיל; איפוס ~{cd_dt.astimezone(IDT):%H:%M IDT}.") # 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: elif hung:
mode = "hung" mode = "hung"
ok, detail = trigger_drain(*win) ok, detail = trigger_drain(*win)