simbiot-harness/harness/scheduler.py

982 lines
No EOL
57 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Simbiot Harness — scheduler: тактовая лестница + cycle runners.
Правила:
- тик уровня идемпотентен (UNIQUE level:seq);
- один исполнитель на уровень → сериализация;
- занятый уровень поглощает тик (TickAbsorbed) и пишет
детерминированную дайджест-заглушку — без LLM;
- запущенная задача не прерывается.
"""
import json
import os
import threading
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 (если есть);
# 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 <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}"
# t-261002085926: Alex ответил (reply) на конкретное
# сообщение — покажи якорь, иначе триаж не понимает,
# к какой реплике относится сообщение
+ (f" [в ответ на «{m.get('reply_to_text','').strip()[:200]}»]"
if m.get("reply_to_message_id") is not None
and (m.get("reply_to_text") or "").strip()
else (" [reply на сообщение без текста]"
if m.get("reply_to_message_id") is not None else ""))
+ ": " + 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 _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)
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)
# Индикация «взял и работаю» (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", {
"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", {
"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)
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" # тик этого такта уже был (идемпотентность)
# северная звезда (t-260929161138): дневной уровень раз в сутки пишёт
# AumMeasured снапшот (AUM + burn 9router) + обновляет aum_target в policy
if level_cfg.name == "day":
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:
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: не маскировать исходную ошибку
# авто-уведомление 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
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 уже сделал большую часть)
# Окно на первый след воркера (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 = {}
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()