"""Simbiot Harness — scheduler: тактовая лестница + cycle runners. Правила: - тик уровня идемпотентен (UNIQUE level:seq); - один исполнитель на уровень → сериализация; - занятый уровень поглощает тик (TickAbsorbed) и пишет детерминированную дайджест-заглушку — без LLM; - запущенная задача не прерывается. """ import json import os import sqlite3 import subprocess import threading import time import traceback from datetime import datetime, timezone, timedelta from . import es from .levels import LEVELS from . import paths as _paths from . import runtime from . import commands from .commands import _ORDER # интервал демона POLL = 1.0 # уровни, включённые в v0.1 (порядок для лога) ACTIVE = ["sec", "min", "hour", "day"] # состояние исполнителей уровня: level → {"busy": bool, "task": id|None} _runners = {} # когда уровень последний раз слал TG-уведомление о занятости (анти-спам) _NOTIFY_TS = {} def _now(): return datetime.now(timezone.utc) def _log(msg: str): print(f"[{datetime.now().strftime('%H:%M:%S')}] {msg}", flush=True) def tick_number(level_cfg, ts: int): return ts // level_cfg.every def emit_tick(level_cfg, seq: int): """CycleTicked идемпотентно. Возвращает True если это новый тик.""" r = es.append_tick(level_cfg.name, seq) return r is not None def deterministic_digest_stub(level_cfg, seq: int, reason: str): """Заглушка дайджеста для поглощённого тика — детерминированно, без LLM.""" period = datetime.fromtimestamp(seq * level_cfg.every, tz=timezone.utc) es.append_event("cycle", f"stub:{level_cfg.name}:{seq}", "DigestWritten", { "level": level_cfg.name, "period": period.isoformat(), "summary_ref": None, "text": f"[absorbed:{reason}] уровень {level_cfg.name} был занят/пропущен; такт {seq}", "stub": True, }) # ──────────── БАТЧ-ВЫБОР ЗАДАЧ В ТИК (t-260930101325-010ba3) ──────────── # Alex: «почему тик берёт только одну задачу, когда мог бы сделать все?» # Причина: find_task возвращал ОДНУ ready-задачу за такт — при 5+ ready # задачи копились быстрее, чем исполнялись (1 задача/такт, час = 60 мин/шт). # Входящие TG уже батчатся одним сообщением (t-260930064924) — очередь # задач теперь тоже: тик берёт ВСЕ готовые задачи своего класса, # пока влезают в бюджет контекста и лимит пачки. MAX_TASK_BATCH = {"min": 3, "hour": 6, "day": 6} # env override: SIMBIOT_BATCH_MAX_ # доля символьного бюджета контекста, которую можно отдать под батч задач # (остаток — мандат, выжимки, эскалации, инбокс, доска) TASK_BUDGET_SHARE = 0.5 # консервативный коэффициент символ→токен для русского текста (2–3.5 факт) CHARS_PER_TOKEN = 3 def _batch_cap(level_cfg): """Лимит задач в пачке уровня (env SIMBIOT_BATCH_MAX_ > дефолт).""" env = os.environ.get(f"SIMBIOT_BATCH_MAX_{level_cfg.name.upper()}") if env: try: return max(1, int(env)) except ValueError: pass return MAX_TASK_BATCH.get(level_cfg.name, 3) def _resolve_reply_target(model: dict, m: dict): """t-261013131228: Alex реплаит на документ/фото — Telegram не отдаёт их текст (reply_to_text пуст), а reply_to_message_id резолвится только по MessageReceived в этом же чате. Ищем «голову» в инбоксе по (chat_id, message_id) — включая kind=document/photo — и возвращаем её описание (caption/файл) или None.""" rid = m.get("reply_to_message_id") if rid is None or (m.get("reply_to_text") or "").strip(): return None for cand in (model.get("inbox") or {}).values(): if cand.get("chat_id") == m.get("chat_id") and cand.get("message_id") == rid: kind = cand.get("kind") or "text" txt = (cand.get("text") or "").strip() if kind in ("document", "photo", "audio", "video", "video_note", "voice"): label = cand.get("file_name") or cand.get("file_path") or kind return f"{kind} «{label}»" + (f": {txt[:300]}" if txt else " (без подписи)") return txt[:300] if txt else None return None def _recent_docs(model: dict, chat_id, limit=3): """Последние N документов/фото этого чата (caption) — контекст для реплаев на сообщения без текста (t-261013131228): триаж видит, о каком документе речь.""" docs = [m for m in (model.get("inbox") or {}).values() if m.get("chat_id") == chat_id and m.get("kind") in ("document", "photo")] docs.sort(key=lambda x: x.get("ts") or "") out = [] for d in docs[-limit:]: label = d.get("file_name") or d.get("mime_type") or d.get("kind") cap = (d.get("text") or "").strip()[:200] out.append(f"«{label}»" + (f" — {cap}" if cap else " (без подписи)")) return out def _est_minutes(t: dict) -> int: """Оценка длительности: из TaskSubmitted или дефолт по классу. est_minutes пишется в событие TaskSubmitted (commands.submit_task) и поднимается проекцией (es.py); для старых задач — дефолт по классу. Нужна для сводной оценки батча в контексте (исполнитель планирует тайм-бюджет тика) — НЕ жёсткий фильтр (оценка ≠ факт). """ est = t.get("est_minutes") if isinstance(est, (int, float)) and est > 0: return int(est) return {"S": 5, "M": 60, "L": 480, "XL": 1440}.get(t.get("size"), 30) def _task_cost(t: dict) -> int: """Символьная стоимость задачи в контексте тика (для бюджет-отсечки).""" return 260 + len(t.get("title") or "") + len(t.get("details") or "") def task_batch_cost(tasks: list) -> int: """Суммарная символьная стоимость батча (для тестов и лога).""" return sum(_task_cost(t) for t in tasks) def find_task(level_cfg, model: dict): """Одиночный выбор: ПЕРВАЯ ready-задача класса уровня (для совместимости).""" classes = level_cfg.task_classes for tid, t in model["tasks"].items(): if t["status"] == "ready" and t.get("size") in classes: return tid return None def select_task_batch(level_cfg, model: dict, prioritize: bool = False): """Батч ready-задач доступных классов в бюджете контекста тика. Возвращает [task_id, ...] — от старейших к свежим (анти-старение очереди). Ограничения: 1) класс задачи ∈ task_classes уровня (как раньше у find_task); 2) кап пачки MAX_TASK_BATCH (env SIMBIOT_BATCH_MAX_) — страховка: тик не превращается только в исполнение задач, дайджест уровня остаётся его работой; 3) бюджет контекста: секция задач не съедает больше ctx_tokens·CHARS_PER_TOKEN·TASK_BUDGET_SHARE символов — мандат, выжимки, эскалации и инбокс остаются в тике. Первая (старейшая) задача берётся всегда — как раньше find_task; бюджетная отсечка работает начиная со второй пачки. prioritize=True (боевой тик): decision-модель (harness/decision.py, режим SIMBIOT_DECISION) выбирает из шортлиста ≤ 9 старейших ту, что больше всего даёт AUM, — она встаёт первой; остальные в FIFO. Модель недоступна/выключена — чистый FIFO, как раньше. """ classes = level_cfg.task_classes if not classes: return [] ready = [t for t in (model.get("tasks") or {}).values() if t.get("status") == "ready" and t.get("size") in classes] ready.sort(key=lambda t: t.get("created") or "") if prioritize and len(ready) > 1: from . import decision pick = decision.prioritize_tasks(level_cfg.name, ready, model) if pick: ready.sort(key=lambda t: t["id"] != pick) # stable: остальные — FIFO cap = _batch_cap(level_cfg) budget_chars = int(level_cfg.ctx_tokens * CHARS_PER_TOKEN * TASK_BUDGET_SHARE) picked, used = [], 0 for t in ready: cost = _task_cost(t) if picked and used + cost > budget_chars: break # бюджет контекста исчерпан — хвост ждёт следующих тактов picked.append(t) used += cost if len(picked) >= cap: break return [t["id"] for t in picked] def _lvl_rank(name: str) -> int: """Ранг уровня в лестнице; неизвестный → ниже всех (эскалации ему не адресованы).""" return _ORDER.index(name) if name in _ORDER else len(_ORDER) # ────────────────────────── ГЕЙТ ВАЖНОСТИ (e-260930062242/2510/2607/2741) ────────────────────────── # Alex: LLM-тик уровня запускается ТОЛЬКО если есть событие ≥ порога важности # этого уровня; иначе тик отрабатывает чисто детерминированной механикой (как sec). # Ранжирование: critical — входящее TG без ответа, готовая задача уровня, # открытая critical-эскалация; warn — warn-эскалации, высокая нагрузка; # всё остальное — тишина, LLM не нужен (не тратим кредиты роутера). # порог по умолчанию на уровень (env-оверрайд: SIMBIOT_GATE_=critical|warn|off) _GATE_DEFAULTS = {"min": "critical", "hour": "warn", "day": "warn"} def _gate_threshold(level_cfg) -> str: """Порог важности уровня: off — гейт выключен, LLM всегда.""" env = os.environ.get(f"SIMBIOT_GATE_{level_cfg.name.upper()}") if env: return env.strip().lower() return _GATE_DEFAULTS.get(level_cfg.name, "off") # медиа с содержимым ждут ответа и без подписи: фото/документ/видео без # текста раньше выпадали из триажа — висели до 23 ч или без ответа вовсе # (аудит 2026-10-16: все 4 неотвеченных за неделю — фото/документы). # Стикер без текста — не повод будить LLM. video_note при приёме → "video". _MEDIA_KINDS = ("voice", "audio", "video", "video_note", "photo", "document", "animation") def _media_fresh(m: dict) -> bool: """Медиа без подписи ждёт ответа SIMBIOT_MEDIA_PENDING_H часов (6): старые фото/документы, выпавшие из триажа до фикса, не будят LLM задним числом.""" try: hours = float(os.environ.get("SIMBIOT_MEDIA_PENDING_H", "6")) ts = datetime.fromisoformat(m.get("ts") or "") except ValueError: return False if ts.tzinfo is None: ts = ts.replace(tzinfo=timezone.utc) return _now() - ts <= timedelta(hours=hours) def _is_pending(m: dict) -> bool: """Входящее ждёт ответа LLM: не отвечено/не проигнорировано, не команда, есть текст или свежее медиа с содержимым.""" from . import tg_commands if m.get("replied") or m.get("ignored") or tg_commands.parse(m.get("text") or ""): return False # группа: только адресованное боту (harness/addressing.py, аудит 2026-10-17); # без вердикта триажа — ещё не входящее для LLM if m.get("chat_type") in ("group", "supergroup") and not m.get("addressed"): return False if m.get("text"): return True if m.get("kind") in ("voice", "audio"): return True # как раньше: голосовое без расшифровки — «не расслышал» return m.get("kind") in _MEDIA_KINDS and _media_fresh(m) def _pending_inbox(model: dict): """Входящие TG без ответа (не команды) — материал для минутного LLM.""" return [m for m in (model.get("inbox") or {}).values() if _is_pending(m)] def collect_signals(level_cfg, model: dict, load: float = None): """События, важные именно для этого уровня, с рангом важности. Возвращает [(severity, what, detail)] — чистая функция от read model, без побочных эффектов; используется гейтом LLM и тестом (hermetic). """ sigs = [] # 1) входящее TG без ответа — критично (должен ответить этот или нижний уровень) inbox = _pending_inbox(model) if inbox: ids = ", ".join(("m-" + str(m.get("update_id"))) for m in inbox[:5]) sigs.append(("critical", "unreplied_inbox", f"{len(inbox)} сообщений без ответа ({ids})")) # 2) готовая задача доступного уровня классу — критично (тик должен её взять) ready = [t for t in (model.get("tasks") or {}).values() if t.get("status") == "ready" and t.get("size") in level_cfg.task_classes] if ready: ids = ", ".join(f"{x['id']}[{x.get('size')}]" for x in sorted(ready, key=lambda x: x.get("created", ""))[:5]) sigs.append(("critical", "ready_task", f"готовые задачи: {ids}")) # 3) открытые эскалации, адресованные этому уровню и выше — ранг = severity for e in (model.get("escalations") or {}).values(): if not isinstance(e, dict) or e.get("status") != "open" or e.get("stub"): continue to_level = e.get("to_level", "day") if to_level not in _ORDER or _lvl_rank(level_cfg.name) < _lvl_rank(to_level): continue sev = e.get("severity", "info") if sev in ("critical", "warn", "info"): sigs.append((sev, f"escalation:{e.get('id')}", (e.get("reason") or "")[:120])) # 4) высокая нагрузка — вторично (алерт-порог как у сек-рефлекса) if load is not None: thr = (model.get("policy") or {}).get("cpu_alert_threshold", 8) if load >= thr: sigs.append(("warn", "high_load", f"load1 {load:.2f} >= {thr}")) return sigs def gate_allows_llm(level_cfg, signals) -> bool: """Решение гейта: запускать ли LLM-тик по порогу важности уровня.""" thr = _gate_threshold(level_cfg) if thr == "off": return True order = {"critical": 0, "warn": 1, "info": 2} if thr not in order: return True rank = order[thr] return any(order.get(s[0], 3) <= rank for s in signals) def build_context(level_cfg, model: dict, batch=None): """Сборка контекста тика: мандат + память уровня + выжимки нижних + батч задач. batch — список задач-словарей (проекции) этого тика; одиночная задача (legacy) передаётся списком из одного элемента. """ parts = [level_cfg.mandate] # идентичность/цели v0.1 — статично из policy (если есть); # north-star метрика AUM (t-260929161138) — тоже policy, инжект всем уровням policy = model.get("policy") if policy and policy.get("identity"): parts.append("ИДЕНТИЧНОСТЬ: " + policy["identity"]) if policy and policy.get("aum_target"): parts.append("ЦЕЛЬ (AUM): " + policy["aum_target"]) # выжимки нижних уровней: последние дайджесты order = ["min", "hour", "day"] idx = order.index(level_cfg.name) if level_cfg.name in order else 0 lower = order[:idx] digests = [] for lvl in lower: for key, d in model["digests"].items(): if d["level"] == lvl and not d.get("stub"): digests.append(d) digests.sort(key=lambda d: d["ts"], reverse=True) if digests: parts.append("ВЫЖИМКИ НИЖНИХ УРОВНЕЙ (последние, сжатые):") # дедуп по тексту (эксп. #1, t-261005121525): тихий мин-уровень давал # 6 идентичных [gate:quiet]-строк в каждом шаге промпта — чистый burn. # Теперь повтор схлопывается в одну строку с счётчиком ×N. seen: dict = {} for d in digests[:6]: key = (d["level"], d["text"][:400]) seen[key] = seen.get(key, 0) + 1 for (lvl, text), n in seen.items(): rep = f" (×{n} подряд)" if n > 1 else "" parts.append(f"- [{lvl}] {text}{rep}") # эскалации: открытые, адресованные этому уровню и выше — в приоритете escs = [] for e in (model.get("escalations") or {}).values(): if not isinstance(e, dict) or e.get("status") != "open" or e.get("stub"): continue to_level = e.get("to_level", "day") if to_level not in _ORDER or _lvl_rank(level_cfg.name) < _lvl_rank(to_level): continue escs.append(e) if escs: escs.sort(key=lambda e: ({'critical': 0, 'warn': 1, 'info': 2}.get(e.get("severity"), 3), e.get("ts", "")), reverse=False) lines = [] for e in escs[:10]: who = f"от {e.get('from_level')}" if e.get("from_level") else "" lines.append(f"- [{e['id']}] ({e.get('severity','info')}) {who} → " f"{e.get('to_level')}: {e.get('reason','')[:300]}" + (f"\n контекст: {e.get('context','')[:400]}" if e.get("context") else "")) parts.append("ЭСКАЛАЦИИ (важное с нижних уровней; отработай и закрой: " "./venv/bin/python cli.py escalation-ack ):\n" + "\n".join(lines)) # инбокс: входящие TG-сообщения, ждущие ответа (в приоритете). # Голосовое с пустой расшифровкой (тишина/отказ) тоже показываем — # минутный уровень должен знать, что голосовое пришло, но текста нет, # и ответить «не расслышал», а не делать вид, что сообщения не было. # Команды бота (/лестница и др.) — НЕ здесь: их мгновенно закрывает # сек-рефлекс (t-260929210755), мин-LLM на них не тратится. Фильтруем # даже без ответа — иначе гонка на границе тика (команда пришла, # сек ещё не отвечал) или лежащий TG заставляли минуту отвечать LLM. inbox = _pending_inbox(model) if inbox: inbox = sorted(inbox, key=lambda x: x.get("ts") or "")[-50:] # кап: не хвост, а ВЕСЬ батч lines = [] ids = [] for i, m in enumerate(inbox, 1): m_id = "m-" + str(m.get("update_id")) ids.append(m_id) who = m.get("username") or m.get("first_name") or m.get("user_id") tag = "" if m.get("kind") == "voice": if m.get("transcribed") and m.get("text"): tag = " [голосовое, расшифровка]" else: tag = (" [голосовое, расшифровка не удалась — попроси прислать текстом]" if m.get("transcribed") else " [голосовое, ещё не расшифровано]") elif m.get("kind") in _MEDIA_KINDS and m.get("kind") != "voice": fp = m.get("file_path") or m.get("file_name") tag = (f" [{m.get('kind')}" + (f", файл: {fp}" if fp else ", файл не сохранён") + ("" if (m.get("text") or "").strip() else ", без подписи") + "]") lines.append(f"{i}. [{m_id}] от {who}{tag}" # t-261002085926: Alex ответил (reply) на конкретное # сообщение — покажи якорь, иначе триаж не понимает, # к какой реплике относится сообщение # t-261013131228: цель реплая без текста (документ/ # фото) резолвится из инбокса (reply_to_desc) — # триаж видит, ЧТО за документ, и не угадывает + (f" [в ответ на «{(m.get('reply_to_text') or m.get('reply_to_desc') or '').strip()[:200]}»]" if m.get("reply_to_message_id") is not None and ((m.get("reply_to_text") or "").strip() or (m.get("reply_to_desc") or "").strip()) else (" [reply на сообщение без текста]" if m.get("reply_to_message_id") is not None else "")) + ": " + m.get('text','')[:1200]) batch_ids = ",".join(ids) chat_ids = {m.get("chat_id") for m in inbox if m.get("chat_id") is not None} # t-261013131228: если в батче есть reply на сообщение без текста — # добавляем последние документы этого чата с caption, чтобы триаж # видел, о каком документе речь, даже если «голова» старше капа инбокса docs_ctx = {} for m in inbox: if m.get("reply_to_message_id") is not None and not ( (m.get("reply_to_text") or "").strip() or (m.get("reply_to_desc") or "").strip()): docs = _recent_docs(model, m.get("chat_id")) if docs: docs_ctx[str(m.get("chat_id"))] = ( "Последние документы этого чата (возможно, к ним относится reply): " + "; ".join(docs)) if docs_ctx: parts.append("КОНТЕКСТ ДОКУМЕНТОВ ЧАТА:\n" + "\n".join(docs_ctx.values())) # t-261015222515: память разговора — рядом с батчем неотвеченных лежит # нить недавнего диалога (отвеченные реплики + наши ответы) тех же # чатов. Раньше после ReplySent входящее выпадало из контекста тика — # минутный уровень отвечал без памяти о разговоре (Alex про SPA-договор: # «только что же обсуждали»). Источник — та же проекция инбокса # (reply_text из ReplySent), отдельный стор не нужен. replied_hist = [m for m in (model.get("inbox") or {}).values() if m.get("replied") and not m.get("ignored") and m.get("chat_id") in chat_ids and (m.get("text") or "").strip()] if replied_hist: replied_hist = sorted(replied_hist, key=lambda x: x.get("ts") or "")[-12:] hist_lines = [] for m in replied_hist: m_id = "m-" + str(m.get("update_id")) who = m.get("username") or m.get("first_name") or m.get("user_id") line = f"- [{m_id}] {who}: {(m.get('text') or '')[:600]}" rt = (m.get("reply_text") or "").strip() if rt: line += f"\n └ наш ответ: {rt[:600]}" hist_lines.append(line) parts.append("НЕДАВНИЙ РАЗГОВОР (уже отвечено; это память тика — НЕ отвечай " "на них снова, используй как контекст):\n" + "\n".join(hist_lines)) # t-260930064924: батч-триаж — ВСЕ неотвеченные обрабатываются ОДНИМ тиком. # Раньше мин-уровень отвечал по одному за тик (m-516173222..228 — 7 тиков # подряд на один разговор). Теперь в контексте лежит ГОТОВАЯ команда # сводного ответа: -c/--close пишет ReplySent на каждое входящее батча — # без хвостов, на которые гейт важности будил бы минуту снова. parts.append("ВХОДЯЩИЕ СООБЩЕНИЯ — БАТЧ из " + str(len(inbox)) + ": это ОДИН разговор, " "НЕ отвечай по одному за тик и не оставляй на следующие тики. " "Сгруппируй всё в один осмысленный ответ по смыслу всего разговора " "(или сделай отдельные реплаи за этот же тик — но ВСЕ сообщения должны " "получить ответ именно в ЭТОМ тике). " "Сводный ответ: ./venv/bin/python cli.py tg-reply " + batch_ids + " -c \"текст\"\n" + "\n".join(lines)) # доска задач # батч задач этого тика (селекция по budget) — текущая постановка if batch is None: batch_ids = select_task_batch(level_cfg, model) batch = [model["tasks"].get(tid) for tid in batch_ids] batch = [t for t in batch if t] if batch: lines = [] for i, t in enumerate(batch, 1): est = _est_minutes(t) lines.append(f"{i}. {t['id']} [{t['size']}, ~{est}м] {t['title'][:100]}") if t.get("details"): lines.append(f" ПОСТАНОВКА: {t['details'][:1800]}") parts.append( f"ТЕКУЩАЯ РАБОТА — БАТЧ из {len(batch)} задач (выполняй в этом тике, " "по порядку списка — первая самая важная; не растягивай: тик ограничен таймаутом и " f"бюджетом шагов; суммарная оценка ~{sum(_est_minutes(t) for t in batch)}м):\n" + "\n".join(lines) + "\nКаждую завершённую закрывай ОТДЕЛЬНО сразу после выполнения: " "./venv/bin/python cli.py complete -a \"результат\" " "(идемпотентно: завершённая уже не завершится повторно; с флагом " "await_delivery результат сам уйдёт в чат). Не успевшая — вернись " "в следующий тик (она останется/вернётся в ready). Сводный итог " "и по каждой задаче — в дайджест." ) tasks_board = [t for t in model["tasks"].values() if t["status"] in ("ready", "running")] if tasks_board: lines = [f"- {t['id']}: {t['title'][:80]} [{t['size']}] {t['status']}" for t in tasks_board[:15]] parts.append("ДОСКА ЗАДАЧ:\n" + "\n".join(lines)) return "\n\n".join(parts) def _notify_busy(level_cfg, task): """TG-visibility: уровни сами сообщают о занятости. Анти-спам: по одному уведомлению на уровень не чаще раза в 15 минут — повторные взятия задач внутри часа не заспамят чат. """ global _NOTIFY_TS now = time.time() if now - _NOTIFY_TS.get(level_cfg.name, 0) < 900: # 15 минут return _NOTIFY_TS[level_cfg.name] = now from . import tg_inbox tg_inbox.notify_chat( f"🔧 Уровень {level_cfg.name} взял задачу [{task.get('size', '?')}] " f"«{task.get('title', '')[:70]}»\n" f"Срез лестницы по запросу: /лестница" ) def _guard_budget_stub_requeue(claimed, text: str): """GUARD (e-261001074527-f0c1b4): финальный текст тика — отписка воркера «бюджет исчерпан» (стоп-сообщение, не результат) → задачи пачки без TaskCompleted возвращаются в ready. e-261001083543-0eab6d: между claim и guard-фазой задача может УЖЕ быть в ready (task_promoted вернул claim в очередь сразу после краха воркера — тот же тик), Тогда requeue_task кидает ValueError «requeue не нужен» и валил весь тик: дайджест/спенд/завершение других задач пачки терялись. Фикс: requeue для ready невозможен по определению — тихо пропускаем. TaskCompleted здесь не пишется никогда (это не результат работы). """ if not claimed: return text, [] from .commands import requeue_task as _requeue reason = ("guard: финал тика «бюджет исчерпан» — работа не завершена, " "задача в ready (e-261001074527)") for tid in (claimed or []): try: _requeue(tid, reason=reason) except ValueError as e: _log(f"guard: requeue {tid} пропущен ({str(e)[:80]}) — уже в ready") return text, [] def _task_still_active(task_id: str) -> bool: """Живая ли задача для целей requeue/fail при смерти воркера тика. e-261004144805-b9d6aa: воркер мог успеть закрыть задачу (TaskCompleted) ВНУТРИ длинного тика до таймаута — тогда слепой TaskPromoted/TaskFailed по списку claimed воскрешал готовую работу. Истина — свежая проекция, а не список claim'ов на старте тика. Только running считается активной; ошибка чтения → консервативно True (прежнее поведение). """ try: model = es.run_projections() t = (model.get("tasks") or {}).get(task_id) return bool(t) and t.get("status") == "running" except Exception: return True def run_level_tick(level_cfg, seq: int, model: dict): """Один тик уровня с LLM: дайджест + (опц.) БАТЧ задач. Возвращает (text, [task_id]). LLM-тик гоняется в изолированном сабпроцессе (isolated_runner): у Strands глобальный asyncio-рантайм, второй create_harness() в том же процессе валит его («cannot schedule new futures»). Батч (t-260930101325): тик берёт ВСЕ ready-задачи своего класса, влезающие в бюджет контекста и кап пачки — одна за такт была медленнее пополнения очереди. Claim каждой — ДО старта воркера; завершение/сбой — ПО каждой задаче отдельно, сразу после возврата воркера, чтобы таймаут/краш не потерял уже сделанное. """ from . import isolated_runner batch_ids = select_task_batch(level_cfg, model, prioritize=True) batch = [model["tasks"].get(tid) for tid in batch_ids] batch = [t for t in batch if t] claimed = [] stale = [] # заняты другим уровнем после снапшота модели (гонка клейма) for t in batch: # t-261015000631-13aef0: атомарный условный клейм НА УРОВНЕ СТОРА # (проверка статуса + INSERT в одной BEGIN IMMEDIATE транзакции). # Гонка 2026-10-15 00:00: hour и day читали модель ДО клейма друг # друга → L-задача t-261014221908 клеймнута дважды (TaskClaimed # id=2767823/2767828) и исполнена дважды = двойной burn 9router. # Снапшот модели здесь — вчерашний; истина только в сторе. res = es.claim_task_if_ready(t["id"], { "level": level_cfg.name, "session_id": f"s-{level_cfg.name}-{seq}", }) if not res: # False — уже не ready (взята/закрыта), None — конфликт записи: # события нет, клейма нет → пропускаем, задача не наша. stale.append(t["id"]) continue claimed.append(t["id"]) # TG-visibility: взяли задачу — сообщаем в чат (анти-спам: не чаще 15м на уровень) try: _notify_busy(level_cfg, t) except Exception: pass # уведомление — best effort, не валить тик if stale: _log("claim-race: пропущены задачи, занятые другим уровнем/не ready: " + ", ".join(stale)) # в промпт — только задачи, чей клейм реально наш batch = [t for t in batch if t["id"] in set(claimed)] if not claimed: claimed = None prompt = build_context(level_cfg, model, batch) # Индикация «взял и работаю» (e-261002202857/73bbaf): пока LLM-тик гоняется, # фоновый поток держит 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() 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: try: text, usage, trace_id = isolated_runner.run_llm_tick( level_cfg, prompt, session_id=f"s-{level_cfg.name}-{seq}", trace_prefix=f"{level_cfg.name}-{seq}", ) except Exception as e: # e-260930162600-cbb7e2/cbb7e2-семейство: TimeoutExpired от isolated_runner # пролетал МИМО блока text-is-None ниже — claimed-задачи висели в running # до сирот-чистки (час+), очередь казалась «не стартующей» (85246b). # Возвращаем claim в очередь сразу (TaskPromoted — тот же механизм, что # orphan-requeue: ready + сброс уровня; следующий тик возьмёт заново), # исключение летит дальше — CycleError/эскалация как раньше. # # e-261004144805-b9d6aa: слепой TaskPromoted по ВСЕМУ claimed # воскрешал задачи, которые воркер успел закрыть ВНУТРИ тика # до таймаута (кейс: 131358 complete+delivery в 14:31, тик убит # в 14:48 → TaskPromoted вернул готовую задачу в очередь). # Фильтруем по живой проекции: возвращаем только реально running. for tid in (claimed or []): if not _task_still_active(tid): continue size = next((t.get("size", "M") for t in batch if t.get("id") == tid), "M") es.append_event("task", tid, "TaskPromoted", {"to": size, "from": size, "reason": f"тик-воркер умер ({str(e)[:120]}) — " f"claim возвращён в очередь без потери"}) raise if text is None: # воркер упал (таймаут/краш) — НИ одна задача не выполнена с его # точки зрения, но executor мог частично поработать инструментами. # По одной TaskFailed на каждую claimed — иначе батч висит в running # до orphan_requeue (час+). Сироты всё равно подстрахуют. # e-261004144805-b9d6aa: закрытые внутри тика не трогаем (см. выше). for tid in (claimed or []): if not _task_still_active(tid): continue es.append_event("task", tid, "TaskFailed", { # t-261015083454: мета — уровень/тик-воркер и его трасса "level": level_cfg.name, "trace_id": trace_id, "error": str(usage.get("error", "llm worker failed"))[:500], }) raise RuntimeError(str(usage.get("error", "llm worker failed"))) # дайджест тика period = datetime.fromtimestamp(seq * level_cfg.every, tz=timezone.utc) es.append_event("cycle", f"digest:{level_cfg.name}:{seq}", "DigestWritten", { "level": level_cfg.name, "period": period.isoformat(), "summary_ref": trace_id, "text": (text or "")[:2000], }) # BudgetSpent воркер уже записал сам — инкрементально, после каждого # модельного вызова (устраняет потерю spend при таймауте воркера). # Если usage в результате пуст (воркер не смог разобрать историю — # старый воркер / нетекстовая структура), дублируем итог, чтобы тик # не терялся в spend вовсе: дедуп по trace_id в read model. if usage and not usage.get("model_calls"): es.append_event("harness", "budget", "BudgetSpent", { "level": level_cfg.name, "trace_id": trace_id, "source": "runner", **{k: v for k, v in usage.items() if k != "error"}, }) # БАТЧ-ЗАВЕРШЕНИЕ (t-260930101325): TaskCompleted по КАЖДОЙ задаче # пачки (общий результат-дайджест — answer каждой; per-task отчёт # исполнитель пишет в дайджесте). Доставка — той же командой # commands.deliver_result (идемпотентна: без флага/уже sent — Skip). # Ответ урезаем на задачу, чтобы 6 задач не раздули 6×2000. # GUARD (e-261001074527-f0c1b4): финальный текст тика «бюджет исчерпан» — # стоп-сообщение воркера, а не результат; ночь 20727 им фейково закрылись # 5 L-задач. Такой тик работы не завершал — задачи возвращаются в ready. from .commands import looks_like_budget_stub if (text or "") and looks_like_budget_stub(text): return _guard_budget_stub_requeue(claimed, text) # guard двойного закрытия (r2 e2e cron-migrate 2026-10-03): воркер мог # ЗАКРЫТЬ задачу сам (cli complete) ещё до батч-фазы — его TaskCompleted # содержит целевой ответ; повторный append поверх перезаписал бы answer # дайджестом тика и испортил бы проверенный результат (перезапись # «последний-победил» в проекции). ЗАКРЫВАЕМ ТОЛЬКО ЕЩЁ-RUNNING: # воркер мог решить сам — complete (done), requeue (готово) или # закрыть в архив (closed); дайджест тика не перезаписывает его # решения (авто-схлопывание фейлов, e-261003094432: без этого фикса # requeue'd задачи нечестно закрывались бы дайджестом). _model = es.run_projections() if _model is None: # стаб/None в тестах (mocked es) — прежний путь: закрываем все claimed _resolve = list(claimed or []) else: _tasks = _model.get("tasks") or {} _resolve = [tid for tid in (claimed or []) if (_tasks.get(tid) or {}).get("status") == "running"] _skipped = sorted(set(claimed or []) - set(_resolve)) if _skipped: _log("skip батч-догзакрытие задач, решённых воркером: " + ", ".join(_skipped)) for tid in _resolve: es.append_event("task", tid, "TaskCompleted", { # t-261015083454: мета-поля — какой тик/уровень закрыл задачу "level": level_cfg.name, "trace_id": trace_id, "result_ref": trace_id, "answer": (text or "")[:2000], }) try: from . import commands as _cmd if _cmd.deliver_result(tid, text or "", owner=f"tick:{level_cfg.name}"): _log(f"delivery: результат {tid} доставлен в TG") except Exception as e: # доставка — best effort: не валить тик; DeliverySent допишет # delivery-демон (см. deliver_pending) — задача не потеряется _log(f"delivery: отправка {tid} не удалась ({e}) — доставит delivery-демон") return text, claimed or [] finally: keep_stop.set() # индикатор «работаю» гасим на любом исходе тика if keepalive is not None and keepalive.is_alive(): keepalive.join(timeout=3) # ── репо-гигиена: fetch origin в дневном ритме (t-261007082307-2e2294) ────── # Инцидент: локальная линия 2 дня не видела 32 коммита origin/master (найдено # случайно в день 10-07). Причина: git fetch нигде не выполнялся регулярно — # расхождение не имело детектора. Фикс: детерминированный шаг в дневном тике # (не в память LLM) — fetch + behind-статус + эскалация при behind>0 после # двух дней подряд (стейт из истории RepoSynced в сторе, не из файла — # рестарт демона счётчик дней не сбрасывает). _REPO_SYNC_MAX_AGE_DAYS = int(os.environ.get( "SIMBIOT_REPO_SYNC_MAX_AGE_DAYS", "2")) def _repo_sync_check(level_cfg): """git fetch origin + замер behind: пишем RepoSynced, эскалируем дрейф. Вызывается из дневного тика (раз в сутки). Один замер на день: повторный вызов в тот же день НЕ плодит записи (append_event версионирует, а не дедуплицирует — проверяем стор перед записью). Ошибка git (нет сети/ кредов) фиксируется в событии без падения тика: behind=unknown честнее выдуманного 0, но не блокирует работу. """ today = _now().strftime("%Y-%m-%d") day_key = f"repo-sync:{today}" root = _paths.REPO_ROOT try: subprocess.run(["git", "fetch", "origin"], cwd=root, capture_output=True, timeout=180, check=True) fetch_ok, err = True, None except Exception as e: fetch_ok, err = False, str(e)[:300] behind = ahead = None if fetch_ok: try: def _cnt(args): r = subprocess.run(["git", "rev-list", "--count"] + args, cwd=root, capture_output=True, timeout=30, check=True) return int(r.stdout.decode().strip() or 0) behind = _cnt(["master..origin/master"]) ahead = _cnt(["origin/master..master"]) except Exception as e: err = str(e)[:300] if not _synced_today(day_key): es.append_event("harness", day_key, "RepoSynced", { "ts": _now().isoformat(), "fetch_ok": fetch_ok, "behind": behind, "ahead": ahead, "error": err, }) if behind is not None and behind > 0: _maybe_escalate_repo_drift(level_cfg, today, behind, ahead) return {"fetch_ok": fetch_ok, "behind": behind, "ahead": ahead} def _synced_today(day_key: str) -> bool: """RepoSynced на эту дату уже записан? (анти-дубль в рамках дня)""" try: db = sqlite3.connect(f"file:{es.DB_PATH}?mode=ro", timeout=10, uri=True) row = db.execute( "SELECT 1 FROM events WHERE event_type='RepoSynced' " "AND aggregate_id=? LIMIT 1", (day_key,)).fetchone() db.close() return row is not None except Exception: return False # стор недоступен → пишем (лучше дубль, чем потеря) def _consecutive_days_behind(today: str, today_behind) -> int: """Сколько дней ПОДРЯД (заканчивая сегодня) behind был > 0. Сегодня берётся из ЖИВОГО замера (событие дня могло быть записано раньше с другим значением — замер один на день). Прошлые дни — из истории RepoSynced в сторе: рестарт демона счётчик не сбрасывает. """ if not today_behind or int(today_behind) <= 0: return 0 try: db = sqlite3.connect(f"file:{es.DB_PATH}?mode=ro", timeout=10, uri=True) rows = db.execute( "SELECT DISTINCT substr(timestamp,1,10) d FROM events " "WHERE event_type='RepoSynced' " "AND COALESCE(json_extract(data,'$.behind'),0) > 0 " "AND substr(timestamp,1,10) < ? ORDER BY d DESC", (today,)).fetchall() db.close() except Exception: return 1 # подсчёт прошлых дней не удался — считаем только сегодня run, expect = 1, ( datetime.strptime(today, "%Y-%m-%d") - timedelta(days=1)).strftime("%Y-%m-%d") for (d,) in rows: if d == expect: run += 1 expect = (datetime.strptime(expect, "%Y-%m-%d") - timedelta(days=1)).strftime("%Y-%m-%d") else: break return run def _maybe_escalate_repo_drift(level_cfg, today: str, behind: int, ahead): """Эскалация при behind>0 N дней ПОДРЯД (порог SIMBIOT_REPO_SYNC_MAX_AGE_DAYS).""" days = _consecutive_days_behind(today, behind) if days < _REPO_SYNC_MAX_AGE_DAYS: return import secrets as _secrets esc_id = f"e-{_now().strftime('%y%m%d%H%M%S')}-{_secrets.token_hex(3)}" es.append_event("harness", esc_id, "EscalationRaised", { "id": esc_id, "from_level": level_cfg.name, "to_level": commands.level_above(level_cfg.name) or "day", "reason": f"репо отстаёт от origin/master: behind={behind} уже " f"{days} дн. подряд — само не разрешится, нужен merge/pull", "context": f"RepoSynced repo-sync:{today}: behind={behind}, ahead={ahead}", "severity": "warn", "task_id": None, "source": "repo-sync", }) def process_level(level_cfg, model: dict, seq: int = None): """Тик одного уровня: занят? → absorb; иначе работа. seq — номер такта от демона (внеочередной мин-тик по входящему берёт слот следующего такта); None — текущий такт по часам. """ st = _runners.setdefault(level_cfg.name, {"busy": False, "task": None}) if seq is None: seq = tick_number(level_cfg, int(time.time())) if st["busy"]: deterministic_digest_stub(level_cfg, seq, "busy") es.append_event("cycle", f"absorbed:{level_cfg.name}:{seq}", "TickAbsorbed", {"level": level_cfg.name, "reason": "busy"}) return "absorbed" if level_cfg.name == "sec" and not _sec_heartbeat_due(seq): pass # сек-такт без CycleTicked: идемпотентность держит last_seq демона elif not emit_tick(level_cfg, seq): return "already" # тик этого такта уже был (идемпотентность) # северная звезда (t-260929161138): дневной уровень раз в сутки пишёт # AumMeasured снапшот (AUM + burn 9router) + обновляет aum_target в policy; # репо-гигиена (t-261007082307-2e2294): fetch origin + детектор дрейфа — # расхождение с origin больше не может висеть незамеченным 2+ дня if level_cfg.name == "day": try: _repo_sync_check(level_cfg) except Exception: pass # репо-синк — best effort, не валить дневной тик try: from . import aum as _aum snap = _aum.measure() es.append_event("harness", "aum", "AumMeasured", { "ts": snap["ts"], "aum": snap["aum"], "burn_24h": snap["burn_24h"], "burn_7d": snap["burn_7d"], "policy": {"aum_target": _aum.build_policy_aum_summary(snap)}, }) except Exception: pass # замер — best effort, не валить тик # ГЕЙТ ВАЖНОСТИ (e-260930062242/2510/2607/2741): LLM-тик запускается # только если есть событие ≥ порога уровня; тихий такт отрабатывает # детерминированной механикой (как sec) — без расхода кредитов роутера. if level_cfg.uses_llm: # адресация в группах — ДО гейта: чужой разговор не будит LLM try: from . import addressing if addressing.triage(model, log=_log): model = es.run_projections() except Exception as e: _log(f"addressing: триаж не удался ({e}) — групповые ждут следующего такта") try: load = os.getloadavg()[0] except Exception: load = None signals = collect_signals(level_cfg, model, load) allow = gate_allows_llm(level_cfg, signals) decided_p = None if allow and signals and not any(s[0] == "critical" for s in signals): # серая зона: порог пропустил только warn/info — спросить # decision-модель, стоит ли тратить кредиты (critical — всегда LLM) from . import decision verdict, decided_p = decision.gate_wants_llm(level_cfg.name, signals) if verdict is False: allow = False if not allow: period = datetime.fromtimestamp(seq * level_cfg.every, tz=timezone.utc) es.append_event("cycle", f"digest:{level_cfg.name}:{seq}", "DigestWritten", { "level": level_cfg.name, "period": period.isoformat(), "summary_ref": None, "text": f"[gate:quiet] {level_cfg.name}: событий важнее порога нет " f"({len(signals)} ниже порога) — LLM не запускался", "stub": True, }) es.append_event("cycle", f"gate:{level_cfg.name}:{seq}", "GateSkipped", { "level": level_cfg.name, "threshold": _gate_threshold(level_cfg), "decision_p": decided_p, "signals": [{"severity": s[0], "what": s[1], "detail": s[2]} for s in signals][:10], }) return f"gate:quiet ({len(signals)} signals below threshold)" st["busy"] = True try: if not level_cfg.uses_llm: # секундный уровень: детерминированный рефлекс без LLM text, task_id = _reflex_tick(level_cfg, seq, model) return f"reflex ok {task_id or ''}".strip() text, batch_ids = run_level_tick(level_cfg, seq, model) return f"ran (tasks={','.join(batch_ids) or '-'})" except Exception as e: es.append_event("harness", "errors", "CycleError", { "level": level_cfg.name, "error": str(e)[:1000], "trace": traceback.format_exc()[-2000:], }) # воркер LLM-тика умер — наверх, чтобы верхний уровень узнал сразу: # нижний сам себя не чинит (контекст мал), фикс идёт сверху. try: import secrets as _secrets esc_id = f"e-{datetime.now(timezone.utc).strftime('%y%m%d%H%M%S')}-{_secrets.token_hex(3)}" es.append_event("harness", esc_id, "EscalationRaised", { "id": esc_id, "from_level": level_cfg.name, "to_level": commands.level_above(level_cfg.name) or "day", "reason": f"тик {level_cfg.name} упал: {str(e)[:200]}", "context": traceback.format_exc()[-1500:], "severity": "warn", "task_id": None, "source": "scheduler", }) 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 # Сек-уровень писал CycleTicked + ReflexObserved КАЖДУЮ секунду: 1.35 млн # подписанных событий/нед — 98% стора (аудит 2026-10-16, стор 1.37 ГБ). # Теперь в стор — heartbeat раз в SIMBIOT_SEC_HEARTBEAT с (60) и такты, где # что-то случилось (алерт нагрузки, команды бота, каналы портала/агентов). # router42 бенча и так считает эти события шумом; дашборд видит heartbeat. SEC_HEARTBEAT = int(os.environ.get("SIMBIOT_SEC_HEARTBEAT", "60")) _SEC_HB = {"last": None, "seq": None} def _sec_heartbeat_due(seq: int) -> bool: """Heartbeat-такт сек-уровня: первый после старта и раз в SEC_HEARTBEAT. Решение кешируется на seq — process_level и _reflex_tick видят одно.""" if _SEC_HB["seq"] == seq: return True if SEC_HEARTBEAT <= 1 or _SEC_HB["last"] is None or seq - _SEC_HB["last"] >= SEC_HEARTBEAT: _SEC_HB["last"] = _SEC_HB["seq"] = seq return True return False def _reflex_tick(level_cfg, seq: int, model: dict): """Секундный рефлекс: телеметрия + алерты + без-LLM команды, без LLM. Команды бота (/лестница, /статус, /задачи, /помощь) — мгновенные ответы из read model (t-260929210755): мин-уровень на них не тратится, ReplySent закрывает входящее сразу после отправки. """ import os load = None try: load = os.getloadavg()[0] except Exception: pass period = datetime.fromtimestamp(seq * level_cfg.every, tz=timezone.utc) payload = {"level": "sec", "seq": seq, "load1": load, "ts": period.isoformat()} # алерт-порог: по умолчанию 0.75*nproc (16 ядер → 12; t-261012090606), # policy cpu_alert_threshold переопределяет при наличии try: _default_thr = max(8.0, 0.75 * os.cpu_count()) except Exception: _default_thr = 12.0 thr = (model.get("policy") or {}).get("cpu_alert_threshold", _default_thr) if load is not None and load >= thr: payload["alert"] = f"load {load:.2f} >= {thr}" # без-LLM команды: отвечаем на входящие-команды без ответа try: from . import tg_commands handled = tg_commands.answer_pending(model) if handled: for mid, cmd in handled: _log(f"tg-cmd: {mid} /{cmd} → отвечено (сек-рефлекс)") payload["commands"] = [c for _, c in handled] except Exception as e: # команды — часть рефлекса, но не должны валить телеметрию print(f"[tg-cmd] ответ на команды не удался: {e}", flush=True) # файловый/HTTP канал чата портала (t-261001123121, без Matrix): pull # входящих чата портала + немедленный push уровня-репляев. Канал # независим от TG: работает и на машинах без TG-токена. try: from . import portal_channel as _pch res = _pch.sec_reflex() if not res.get("skipped") and (res.get("ingested") or res.get("outbox_files")): payload["portal_channel"] = res _log(f"portal-channel: {res}") except SystemExit: raise except Exception as e: print(f"[portal-channel] рефлекс не смог: {e}", flush=True) # канал агент↔агент (t-261015090129): очередь ask_agent/poll_messages # MCP-шлюза. Лучший эффект — на мин-уровне: сообщения других агентов # попадают в инбокс триажа как обычные входящие. try: from . import agent_channel as _ach res = _ach.sec_reflex() if not res.get("skipped") and res.get("ingested"): payload["agent_channel"] = res _log(f"agent-channel: {res}") except SystemExit: raise except Exception as e: print(f"[agent-channel] рефлекс не смог: {e}", flush=True) if (_sec_heartbeat_due(seq) or payload.get("alert") or payload.get("commands") or payload.get("portal_channel") or payload.get("agent_channel")): es.append_event("cycle", f"reflex:{seq}", "ReflexObserved", payload) return payload, None # ────────────── АВТОДОСТАВКА (t-260929205927) + СИРОТЫ-РЕСТАРТЫ ────────────── 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 без живого воркера → очередь. Раньше: тик-владелец задачи умирал (systemctl restart демона при деплое убивает и воркеров), TaskFailed/TaskCompleted не пишется — и задача висела в running навсегда (эскалация e-260929234911: cad7af завис дважды за два часа; «повторный таймаут часового воркера» был на самом деле убийством живого воркера рестартом). Мин-уровень вычищал руками. Теперь демон при старте и каждые DELIVERY_POLL секунд: - running-задача с мёртвым воркером (нет свежего следа сессии) → TaskPromoted(ready) с reason 'orphan-requeue'; флаг await_delivery сохраняется, доработка после requeue тоже уйдёт в чат. """ model = es.run_projections() from datetime import timedelta now = _now() requeued = [] for tid, t in (model.get("tasks") or {}).items(): if t.get("status") != "running": continue 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*) каждые секунды-минуты; тишина дольше # его же таймаута означает: воркер уже убит раннером (TimeoutExpired # НЕ пишет TaskFailed — задача и висела в running) или умер с демоном. timeout = (cfg.tick_timeout if cfg else 300) or 300 if cfg and cfg.uses_llm: timeout = max(timeout, int(cfg.every * 0.8)) timeout += 60 # свежесть следа: трейс сессии (aggregate_id 'hour-497423:96') # или BudgetSpent с её trace_id. session 's-hour-497423' → # префикс 'hour-497423' безопасен: след. символ ':' или '"'. prefix = sess[2:] if sess.startswith("s-") else sess db = es._conn() try: row = db.execute( "SELECT MAX(timestamp) AS ts FROM events " "WHERE (aggregate='trace' AND aggregate_id LIKE ?) " " OR (aggregate='harness' AND event_type='BudgetSpent' AND data LIKE ?)", (prefix + "%", "%" + prefix + "%"), ).fetchone() last = row["ts"] if row else None finally: db.close() if last: try: last_dt = datetime.fromisoformat(last) if (now - last_dt) < timedelta(seconds=timeout + 60): continue # воркер жив/недавно работал — не трогаем except ValueError: pass es.append_event("task", tid, "TaskPromoted", {"to": t.get("size") or "M", "from": t.get("size") or "M", "reason": "orphan-requeue: воркер тика-владельца мёртв " "(рестарт/таймаут), задача возвращена в очередь"}) requeued.append(tid) _log(f"orphan-requeue: {tid} (сессия {sess}) → ready") return requeued def deliver_pending(): """Доставка готовых задач с await_delivery, не получивших push. Push (в run_level_tick) закрывает 99% кейсов немедленно; этот pull — страховка: тик умер после TaskCompleted, но до DeliverySent; флаг поставлен позже завершения (старый тик) — досылаем по факту. Идемпотентно: delivery_status=='sent' не отправляет повторно. """ from . import commands as _cmd model = es.run_projections() out = [] for tid, t in (model.get("tasks") or {}).items(): if not (t.get("await_delivery") and t.get("status") == "done" and t.get("delivery_status") != "sent"): continue # идемпотентная доставка (t-260930083937): ledger — истина поверх # stale read model (модель могла не догнать события после рестарта). # Pre-filter без события: DeliverySkipped пишет только deliver_result # (push-гонка), демон молча пропускает — иначе по событию каждые 15с. if es.delivery_recorded(tid): continue # ответ задачи: TaskCompleted.answer (проекция не хранит — берём из стора) db = es._conn() try: row = db.execute( "SELECT data FROM events WHERE aggregate='task' AND aggregate_id=? " "AND event_type='TaskCompleted' ORDER BY id DESC LIMIT 1", (tid,), ).fetchone() finally: db.close() if not row: continue answer = (json.loads(row["data"]).get("answer") or "").strip() try: if _cmd.deliver_result(tid, answer, owner="daemon"): out.append(tid) _log(f"delivery-pull: {tid} доставлен") except Exception as e: _log(f"delivery-pull: {tid} ошибка {e} — повторим на след. проходе") return out def _delivery_and_orphans_loop(): """Фоновый поток демона: доставка + сироты + инициативы, не мешает тикам.""" import threading _log("delivery-демон запущен (orphan-requeue + автодоставка + инициативные реплики)") # первый проход сразу: чистим сирот рестарта, доим недоставленное while True: try: orphan_requeue() deliver_pending() except Exception: pass # демон не должен умирать: следующий проход попробует снова # авто-схлопывание старых фейлов (e-261003094432, просьба Alex): # failed старше 24ч → closed («архив» после done на доске), доска # не зарастает красной группой; requeue вернёт, если нужна снова. try: closed = commands.archive_stale_failed() for tid in closed: _log(f"archive: {tid} failed>24ч → closed") except Exception: pass # инициативные реплики (t-260930073419): без LLM, ритм внутри себя; # ошибки глотаем — реплика подождёт следующего прохода, демон жив try: from . import proactive slot, why = proactive.speak() if slot: _log(f"proactive: инициативная реплика слота {slot} отправлена") except Exception: pass time.sleep(DELIVERY_POLL) def main_loop(): """Демон: уровни тикают независимо, каждый в своём потоке. LLM-тик одного уровня больше не блокирует остальные (раньше минутный тик замораживал и сек-рефлекс, и час).""" import threading es.init_db() _log("Simbiot Harness запущен; уровни: " + ", ".join(ACTIVE)) # delivery-демон: сироты рестартов + автодоставка TG (t-260929205927). # Первый проход сразу — чистка сирот до первого тика уровней. dt = threading.Thread(target=_delivery_and_orphans_loop, daemon=True, name="delivery") dt.start() last_seq = {} threads = {} nudge = MinNudge() def _idle(name): t = threads.get(name) return not _runners.get(name, {}).get("busy") and not (t is not None and t.is_alive()) while True: for name in ACTIVE: cfg = LEVELS[name] seq = tick_number(cfg, int(time.time())) if name == "min" and nudge.due() and _idle(name): # внеочередной мин-тик на входящее: слот следующего такта # (свой seq уже отработан на границе) — граница его пропустит seq = max(seq, last_seq.get(name, seq - 1) + 1) nudge.fired() _log(f"min #{seq}: тик по входящему (не ждём границы минуты)") elif last_seq.get(name, -1) >= seq: continue # этого такта ещё не было — ждём границу last_seq[name] = seq if cfg.uses_llm and _runners.get(name, {}).get("busy"): # уровень ещё работает в предыдущем такте — тик поглощается, # но остальные уровни продолжают жить continue t = threads.get(name) if t is not None and t.is_alive(): continue def _run(cfg=cfg, seq=seq): model = es.run_projections() state = process_level(cfg, model, seq=seq) if state not in ("already",) and not str(state).startswith("reflex ok"): _log(f"{cfg.name} #{seq}: {state}") t = threading.Thread(target=_run, daemon=True, name=f"tick-{name}") threads[name] = t t.start() nudge.poll() time.sleep(POLL) class MinNudge: """Тик мин-уровня по входящему, а не по границе минуты (аудит 2026-10-16). Ответ в TG ждал границы такта: медиана 36 с от приёма до старта LLM (p90 93 с) при 24 с самой модели. Демон раз в POLL дёшево смотрит хвост стора (диапазон по PK от последнего виденного id) на новые MessageReceived; через DEBOUNCE секунд тишины после последнего — внеочередной тик (серия сообщений Alex уходит одним батчем). Занят уровень — нудж ждёт его конца. Гейт важности работает как обычно: команда/чужой/уже отвеченное → тихий такт без LLM. SIMBIOT_MIN_NUDGE=0 — выключить; SIMBIOT_MIN_NUDGE_DEBOUNCE (3 с). """ def __init__(self): self.enabled = os.environ.get("SIMBIOT_MIN_NUDGE", "1") != "0" self.debounce = float(os.environ.get("SIMBIOT_MIN_NUDGE_DEBOUNCE", "3")) self.last_id = None self.pending_since = None # время последнего нового входящего self._db = None def poll(self): if not self.enabled: return try: if self._db is None: self._db = es._conn() db = self._db if self.last_id is None: self.last_id = db.execute("SELECT COALESCE(MAX(id), 0) FROM events").fetchone()[0] return row = db.execute( "SELECT MAX(id), SUM(event_type IN ('MessageReceived', 'MessageTranscribed')) " "FROM events WHERE id > ?", (self.last_id,)).fetchone() except Exception: self._db = None # переоткроем на следующем проходе return # нудж — ускоритель, не критичный путь if row and row[0]: self.last_id = row[0] if row[1]: self.pending_since = time.time() def due(self) -> bool: return (self.pending_since is not None and time.time() - self.pending_since >= self.debounce) def fired(self): self.pending_since = None if __name__ == "__main__": main_loop()