diff --git a/harness/scheduler.py b/harness/scheduler.py index 851bb3e..6c81241 100644 --- a/harness/scheduler.py +++ b/harness/scheduler.py @@ -481,25 +481,36 @@ def run_level_tick(level_cfg, seq: int, model: dict): claimed = None prompt = build_context(level_cfg, model, batch) # Индикация «взял и работаю» (e-261002202857/73bbaf): пока LLM-тик гоняется, - # фоновый поток держит sendChatAction typing в TG-чатах инбокса (обновление - # каждые ~2с) — «печатает…» виден весь тик. Реакция 👀 на конкретные - # входящие — поллером при приёме (MessageSeen) и по требованию cli.py tg-seen. - # Останов потока — в finally в конце функции. + # фоновый поток держит sendChatAction typing (обновление каждые ~2с) — + # «печатает…» виден весь тик. Реакция 👀 на конкретные входящие — поллером + # при приёме (MessageSeen) и по требованию cli.py tg-seen. + # t-261010092931: раньше поток поднимался ТОЛЬКО при непросмотренном + # pending-инбоксе — с пустым/просмотренным инбоксом индикатора не было весь + # тик. Теперь «печатает…» горит весь тик LLM независимо от pending и от + # claimed-задач: чаты берём из всего инбокса модели (seen/replied не важны), + # а при пустом инбоксе — последний живой TG-чат харнеса (_default_chat_id, + # личка Alex). typing — только TG-чаты: портал-каналу и TUI typing API + # недоступен. Останов потока — в finally в конце функции. from . import tg_inbox keepalive = None keep_stop = threading.Event() - pending = [m for m in _pending_inbox(model) if not m.get("seen")] - if pending: - # typing — только TG-чаты (портал-каналу typing API недоступен) - tg_chat_ids = list({m.get("chat_id") for m in pending - if m.get("chat_id") - and not str(m.get("chat_id")).startswith("portal")}) - if tg_chat_ids: - keepalive = threading.Thread( - target=tg_inbox.typing_keepalive, - args=(tg_chat_ids, keep_stop), - name=f"keepalive-{level_cfg.name}-{seq}", daemon=True) - keepalive.start() + tg_chat_ids = list({m.get("chat_id") for m in (model.get("inbox") or {}).values() + if m.get("chat_id") + and not str(m.get("chat_id")).startswith(("portal", "tui"))}) + if not tg_chat_ids: + # пустой инбокс — показываем «печатает…» в чате харнеса, если он известен + try: + _dc = tg_inbox._default_chat_id() + except Exception: + _dc = None + if _dc is not None: + tg_chat_ids = [_dc] + if tg_chat_ids: + keepalive = threading.Thread( + target=tg_inbox.typing_keepalive, + args=(tg_chat_ids, keep_stop), + name=f"keepalive-{level_cfg.name}-{seq}", daemon=True) + keepalive.start() # весь хвост тика — под finally: keepalive гасим на любом исходе # (успех / worker-сбой / исключение), поток не переживает свой тик. try: diff --git a/tests/test_seen_indication.py b/tests/test_seen_indication.py index ffce9e7..db50946 100644 --- a/tests/test_seen_indication.py +++ b/tests/test_seen_indication.py @@ -9,8 +9,9 @@ (нет ни реакции, ни второго события), TG-сбой — False без события; - receive_message сохраняет message_id (нативный, не хэш-фолбэк); - typing_keepalive: держит sendChatAction typing и останавливается по Event; -- run_level_tick: поднимает keepalive по TG-чатам неотвеченного инбокса, - исключает портал-чаты, гасит поток в finally (на любом исходе возврата). +- run_level_tick: поднимает keepalive по TG-чатам инбокса ВЕСЬ тик, без + привязки к pending/seen (t-261010092931), исключает портал-чаты, при + пустом инбоксе — фолбэк на чат харнеса; гасит поток в finally (любой исход). """ import json import os @@ -173,7 +174,9 @@ try: check("tick: worker упал → finally всё равно гасит keepalive", ka_seen.get("stop") is not None and ka_seen.get("stop").is_set()) - # все сообщения уже seen → keepalive не поднимается вовсе + # t-261010092931: всё seen → «печатает…» всё равно горит весь тик + # (индикатор больше не зависит от pending: Alex видит «печатает…» + # всё время, пока LLM-тик работает) ka_seen.clear() model2 = dict(model) model2["inbox"] = { @@ -182,7 +185,50 @@ try: } _ir_module.run_llm_tick = staticmethod(_FakeRunner.run_llm_tick) run_level_tick(LEVELS["hour"], 4244, model2) - check("tick: всё seen → keepalive не поднимается", ka_seen == {}, str(ka_seen)) + check("tick: всё seen → keepalive всё равно поднят по TG-чату", + ka_seen.get("ids") == [42], str(ka_seen)) + check("tick: всё seen → finally погасил keepalive", + ka_seen.get("stop") is not None and ka_seen.get("stop").is_set()) + + # t-261010092931: пустой инбокс → фолбэк на последний живой TG-чат + # (_default_chat_id, личка Alex): индикатор горит и без входящих + ka_seen.clear() + model3 = dict(model) + model3["inbox"] = {} + _orig_default_chat = tg_inbox._default_chat_id + tg_inbox._default_chat_id = lambda: 42 + try: + _ir_module.run_llm_tick = staticmethod(_FakeRunner.run_llm_tick) + run_level_tick(LEVELS["hour"], 4245, model3) + finally: + tg_inbox._default_chat_id = _orig_default_chat + check("tick: пустой инбокс → keepalive по чату харнеса (фолбэк)", + ka_seen.get("ids") == [42], str(ka_seen)) + check("tick: пустой инбокс → finally погасил keepalive", + ka_seen.get("stop") is not None and ka_seen.get("stop").is_set()) + + # инбокс без TG-чатов (только портал/tui) → тоже фолбэк на чат харнеса + ka_seen.clear() + model4 = dict(model) + model4["inbox"] = {"m-2": model["inbox"]["m-2"]} + tg_inbox._default_chat_id = lambda: 42 + try: + _ir_module.run_llm_tick = staticmethod(_FakeRunner.run_llm_tick) + run_level_tick(LEVELS["hour"], 4246, model4) + finally: + tg_inbox._default_chat_id = _orig_default_chat + check("tick: только портал-чаты → keepalive по фолбэку, портал исключён", + ka_seen.get("ids") == [42], str(ka_seen)) + + # фолбэк недоступен (нет известного чата) → keepalive не поднимается + ka_seen.clear() + tg_inbox._default_chat_id = lambda: None + try: + _ir_module.run_llm_tick = staticmethod(_FakeRunner.run_llm_tick) + run_level_tick(LEVELS["hour"], 4247, model3) + finally: + tg_inbox._default_chat_id = _orig_default_chat + check("tick: чат неизвестен → keepalive не поднимается", ka_seen == {}, str(ka_seen)) finally: scheduler.es = _orig_es tg_inbox.typing_keepalive = _orig_tg_typing_backup