- portal_channel.py: HTTP-клиент портала — pull входящих чата портала (GET /api/agent/<имя>/channel/poll?since=ms), push ответов (POST outbox, ack входящих по msg_id), сек-рефлекс (≤20с) + cron main(). - tg_inbox: чаты 'portal:*' доставляются push'ем на портал (ReplySent), reply_to = портальный update_id (portal: ack consumed входящего). - scheduler: ReflexObserved хук на portal_channel.sec_reflex — pull работает и без LLM-тика, и на машинах без TG. - es: проекционный бранчPortalDelivered выпилен (маркер — ReplySent от tg_inbox); MessageIgnored branch остался нетронутым. - Диагноз: SSH портал→агенты закрыт (timeout :22/:8765 к #83) — канал на исходящем HTTP, AGENT_TOKEN+PORTAL_URL уже пекутся в /etc/coop-reporter.env, agent-scope env добавлены в cloud-init .env (SIMBIOT_AGENT_TOKEN/NAME/CHAT=1).
813 lines
No EOL
45 KiB
Python
813 lines
No EOL
45 KiB
Python
"""Simbiot Harness — scheduler: тактовая лестница + cycle runners.
|
||
|
||
Правила:
|
||
- тик уровня идемпотентен (UNIQUE level:seq);
|
||
- один исполнитель на уровень → сериализация;
|
||
- занятый уровень поглощает тик (TickAbsorbed) и пишет
|
||
детерминированную дайджест-заглушку — без LLM;
|
||
- запущенная задача не прерывается.
|
||
"""
|
||
import json
|
||
import os
|
||
import time
|
||
import traceback
|
||
from datetime import datetime, timezone, timedelta
|
||
|
||
from . import es
|
||
from .levels import LEVELS
|
||
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_<LEVEL>
|
||
# доля символьного бюджета контекста, которую можно отдать под батч задач
|
||
# (остаток — мандат, выжимки, эскалации, инбокс, доска)
|
||
TASK_BUDGET_SHARE = 0.5
|
||
# консервативный коэффициент символ→токен для русского текста (2–3.5 факт)
|
||
CHARS_PER_TOKEN = 3
|
||
|
||
|
||
def _batch_cap(level_cfg):
|
||
"""Лимит задач в пачке уровня (env SIMBIOT_BATCH_MAX_<LEVEL> > дефолт)."""
|
||
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 _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):
|
||
"""Батч ready-задач доступных классов в бюджете контекста тика.
|
||
|
||
Возвращает [task_id, ...] — от старейших к свежим (анти-старение
|
||
очереди). Ограничения:
|
||
1) класс задачи ∈ task_classes уровня (как раньше у find_task);
|
||
2) кап пачки MAX_TASK_BATCH (env SIMBIOT_BATCH_MAX_<LEVEL>) —
|
||
страховка: тик не превращается только в исполнение задач,
|
||
дайджест уровня остаётся его работой;
|
||
3) бюджет контекста: секция задач не съедает больше
|
||
ctx_tokens·CHARS_PER_TOKEN·TASK_BUDGET_SHARE символов —
|
||
мандат, выжимки, эскалации и инбокс остаются в тике.
|
||
Первая (старейшая) задача берётся всегда — как раньше find_task;
|
||
бюджетная отсечка работает начиная со второй пачки.
|
||
"""
|
||
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 "")
|
||
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_<LEVEL>=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")
|
||
|
||
|
||
def _pending_inbox(model: dict):
|
||
"""Входящие TG без ответа (не команды) — материал для минутного LLM."""
|
||
from . import tg_commands
|
||
return [m for m in (model.get("inbox") or {}).values()
|
||
if not m.get("replied") and not m.get("ignored")
|
||
and not tg_commands.parse(m.get("text") or "")
|
||
and (m.get("text") or m.get("kind") in ("voice", "audio", "video_note"))]
|
||
|
||
|
||
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 (если есть)
|
||
policy = model.get("policy")
|
||
if policy and policy.get("identity"):
|
||
parts.append("ИДЕНТИЧНОСТЬ: " + policy["identity"])
|
||
|
||
# выжимки нижних уровней: последние дайджесты
|
||
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("ВЫЖИМКИ НИЖНИХ УРОВНЕЙ (последние, сжатые):")
|
||
for d in digests[:6]:
|
||
parts.append(f"- [{d['level']}] {d['text'][:400]}")
|
||
|
||
# эскалации: открытые, адресованные этому уровню и выше — в приоритете
|
||
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 <id>):\n" + "\n".join(lines))
|
||
|
||
# инбокс: входящие TG-сообщения, ждущие ответа (в приоритете).
|
||
# Голосовое с пустой расшифровкой (тишина/отказ) тоже показываем —
|
||
# минутный уровень должен знать, что голосовое пришло, но текста нет,
|
||
# и ответить «не расслышал», а не делать вид, что сообщения не было.
|
||
# Команды бота (/лестница и др.) — НЕ здесь: их мгновенно закрывает
|
||
# сек-рефлекс (t-260929210755), мин-LLM на них не тратится. Фильтруем
|
||
# даже без ответа — иначе гонка на границе тика (команда пришла,
|
||
# сек ещё не отвечал) или лежащий TG заставляли минуту отвечать LLM.
|
||
from . import tg_commands
|
||
inbox = [m for m in (model.get("inbox") or {}).values()
|
||
if not m.get("replied") and not m.get("ignored")
|
||
and not tg_commands.parse(m.get("text") or "")
|
||
and (m.get("text") or m.get("kind") in ("voice", "audio", "video_note"))]
|
||
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
|
||
" [голосовое, ещё не расшифровано]")
|
||
lines.append(f"{i}. [{m_id}] от {who}{tag}: {m.get('text','')[:1200]}")
|
||
batch_ids = ",".join(ids)
|
||
# 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 <task_id> -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 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)
|
||
batch = [model["tasks"].get(tid) for tid in batch_ids]
|
||
batch = [t for t in batch if t]
|
||
claimed = []
|
||
for t in batch:
|
||
es.append_event("task", t["id"], "TaskClaimed", {
|
||
"level": level_cfg.name, "session_id": f"s-{level_cfg.name}-{seq}",
|
||
})
|
||
claimed.append(t["id"])
|
||
# TG-visibility: взяли задачу — сообщаем в чат (анти-спам: не чаще 15м на уровень)
|
||
try:
|
||
_notify_busy(level_cfg, t)
|
||
except Exception:
|
||
pass # уведомление — best effort, не валить тик
|
||
if not claimed:
|
||
claimed = None
|
||
prompt = build_context(level_cfg, model, batch)
|
||
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/эскалация как раньше.
|
||
for tid in (claimed or []):
|
||
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 (час+). Сироты всё равно подстрахуют.
|
||
for tid in (claimed or []):
|
||
es.append_event("task", tid, "TaskFailed", {
|
||
"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)
|
||
for tid in (claimed or []):
|
||
es.append_event("task", tid, "TaskCompleted", {
|
||
"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 []
|
||
|
||
|
||
def process_level(level_cfg, model: dict):
|
||
"""Тик одного уровня: занят? → absorb; иначе работа."""
|
||
st = _runners.setdefault(level_cfg.name, {"busy": False, "task": None})
|
||
if st["busy"]:
|
||
seq = tick_number(level_cfg, int(time.time()))
|
||
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"
|
||
|
||
seq = tick_number(level_cfg, int(time.time()))
|
||
if not emit_tick(level_cfg, seq):
|
||
return "already" # тик этого такта уже был (идемпотентность)
|
||
|
||
# ГЕЙТ ВАЖНОСТИ (e-260930062242/2510/2607/2741): LLM-тик запускается
|
||
# только если есть событие ≥ порога уровня; тихий такт отрабатывает
|
||
# детерминированной механикой (как sec) — без расхода кредитов роутера.
|
||
if level_cfg.uses_llm:
|
||
try:
|
||
load = os.getloadavg()[0]
|
||
except Exception:
|
||
load = None
|
||
signals = collect_signals(level_cfg, model, load)
|
||
if not gate_allows_llm(level_cfg, signals):
|
||
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),
|
||
"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: не маскировать исходную ошибку
|
||
return "error: " + str(e)[:120]
|
||
finally:
|
||
st["busy"] = 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()}
|
||
# алерт-порог из policy (по умолчанию 8 — как simbiotd)
|
||
thr = (model.get("policy") or {}).get("cpu_alert_threshold", 8)
|
||
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)
|
||
es.append_event("cycle", f"reflex:{seq}", "ReflexObserved", payload)
|
||
return payload, None
|
||
|
||
|
||
# ────────────── АВТОДОСТАВКА (t-260929205927) + СИРОТЫ-РЕСТАРТЫ ──────────────
|
||
|
||
DELIVERY_POLL = 15 # сек: проход delivery-демона (push уже сделал большую часть)
|
||
|
||
|
||
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)
|
||
# Порог молчания = таймаут убийства воркера (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 # демон не должен умирать: следующий проход попробует снова
|
||
# инициативные реплики (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 = {}
|
||
while True:
|
||
for name in ACTIVE:
|
||
cfg = LEVELS[name]
|
||
seq = tick_number(cfg, int(time.time()))
|
||
if last_seq.get(name) == 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)
|
||
if state not in ("already",):
|
||
_log(f"{cfg.name} #{seq}: {state}")
|
||
t = threading.Thread(target=_run, daemon=True, name=f"tick-{name}")
|
||
threads[name] = t
|
||
t.start()
|
||
time.sleep(POLL)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main_loop() |