diff --git a/harness/scheduler.py b/harness/scheduler.py index 0ff0b51..94f630d 100644 --- a/harness/scheduler.py +++ b/harness/scheduler.py @@ -581,6 +581,23 @@ def process_level(level_cfg, model: dict): }) except Exception: pass # эскалация — best effort: не маскировать исходную ошибку + # авто-уведомление Alex в TG (e-261001134429-c68495): падение тика + # не должно ждать, пока человек заметит на дашборде или спросит сам. + # Идемпотентно (ledger по esc_id — как tg-notify -k): рестарт демона + # или повторный проход дубль в чат не пришлёт. Best effort: ошибка + # доставки не маскирует исходное падение. + try: + from . import tg_inbox as _tgi + from . import commands as _cmds + _cmds.deliver_artifact( + f"alert:{esc_id}", kind="message", + send=lambda: _tgi.notify_chat( + f"🚨 Тик {level_cfg.name} упал: {str(e)[:300]}\n" + f"Диагноз: {traceback.format_exc()[-400:]}\n" + f"Эскалация {esc_id} поднята наверх — часовой/дневной уровень разберёт."), + owner=f"scheduler:{level_cfg.name}") + except Exception: + pass # уведомление — best effort, не валить обработку ошибки return "error: " + str(e)[:120] finally: st["busy"] = False @@ -638,6 +655,16 @@ def _reflex_tick(level_cfg, seq: int, model: dict): DELIVERY_POLL = 15 # сек: проход delivery-демона (push уже сделал большую часть) +# Окно на первый след воркера (e-261001124424-d9295f / e-261001124837-ab3fa7): +# воркер после клейма грузится секунды-десятки секунд, ПЕРВЫЙ trace появляется +# не мгновенно. Сирота-чистка без окна считала свежевзятую задачу мёртвой +# («мёртв: рестарт/таймаут») через 1-2с после клейма и возвращала в очередь — +# задача ходила по кругу ready↔running, а мин-LLM соседних тиков видел её +# ready и ловил ValueError «завершать можно только running». Grace 90с: +# живой воркер успевает начать писать след, умерший-при-старте ловится сразу +# после окна (не хуже прежнего мгновенного requeue). +CLAIM_GRACE_SEC = 90 + def orphan_requeue(): """Сироты рестартов: задачи в running без живого воркера → очередь. @@ -663,6 +690,18 @@ def orphan_requeue(): sess = t.get("session_id") or "" level = t.get("level") or "hour" cfg = LEVELS.get(level) + # GRACE на первый след (e-261001124837-ab3fa7): задача, взятая ТОЛЬКО + # ЧТО (клейм свежее окна) — не сирота: воркер ещё грузится и первый + # trace/BudgetSpent не успел появиться. Без окна чистка рвала клейм + # через 1-2с и гоняла задачу по кругу ready↔running (кейс «list» + # 12:43-12:48). Окно от claimed_at (если есть) — не от created. + claimed_at = t.get("claimed_at") + if claimed_at: + try: + if (now - datetime.fromisoformat(claimed_at)) < timedelta(seconds=CLAIM_GRACE_SEC): + continue # воркер ещё не обязан был показать след + except ValueError: + pass # Порог молчания = таймаут убийства воркера (isolated_runner: # max(tick_timeout, every*0.8)) + запас. Пока воркер жив, он пишет # след (BudgetSpent/Tool*) каждые секунды-минуты; тишина дольше diff --git a/harness/tg_commands.py b/harness/tg_commands.py index 0e27f62..4378393 100644 --- a/harness/tg_commands.py +++ b/harness/tg_commands.py @@ -30,7 +30,7 @@ _last_fail_ts = 0.0 _ALIASES = { "лестница": ("лестница", "ladder", "lestnica"), "статус": ("статус", "status"), - "задачи": ("задачи", "tasks", "zadachi"), + "задачи": ("задачи", "tasks", "zadachi", "list"), "помощь": ("помощь", "help", "pomoshch", "start", "начать", "команды", "commands"), } _ALIAS_TO_CMD = {} @@ -54,6 +54,8 @@ def parse(text) -> "str | None": Команда: "/имя" (первый токен; @бот отбрасывается) или ОДИНОЧНОЕ слово-алиас без слэша («лестница»). Фраза с пробелами без слэша — не команда (обычная речь, её слышит минутный LLM-уровень). + Алиас "list" (e-261001124837-ab3fa7): «доска» из чата — частая + реплика; без алиаса мин-уровень рожал из неё S-клоны на доске. """ t = (text or "").strip() if not t: diff --git a/tests/test_tg_commands.py b/tests/test_tg_commands.py index 1906b31..82cc938 100644 --- a/tests/test_tg_commands.py +++ b/tests/test_tg_commands.py @@ -58,6 +58,9 @@ check("parse обычная речь", P("привет, как дела?") is No check("parse пусто", P("") is None) check("parse /неизвестная", P("/фыва") is None) check("parse /cmd с аргументом", P("/status сейчас") == "статус") +check("parse aлиас list → задачи (e-261001124837-ab3fa7)", P("list") == "задачи") +check("parse LIST регистр", P("LIST") == "задачи") +check("parse list с пробелами — НЕ задача-клон", P("list please") is None) # ── 2. хендлеры: непустой текст, без исключений, без сырого HTML-рискa ── es.init_db()