- scheduler: _events_digest() — содержательные события стора за ~90 мин (Message/Reply/Tasks/Escalations/AUM/MCP), шум (CycleTicked/Tool*/) отсечён; секция «СОБЫТИЯ ХАРНЕСА» в build_context по policy.events_context=on - commands/cli: policy --events-context on|off (переключатель, одобрен Alex) - tests/test_events_context.py: 9 hermetic-проверок (on/off/дефолт/пустой стор)
1719 lines
No EOL
98 KiB
Python
1719 lines
No EOL
98 KiB
Python
"""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"]
|
||
|
||
# ядро Коди: когда последний раз собирали обратную связь чата
|
||
_CORE_FB = {"ts": 0.0}
|
||
# состояние исполнителей уровня: 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 _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 _own_messages(chat_ids, replied_hist, hours: int = 48, cap: int = 6) -> list:
|
||
"""Собственные исходящие бота (не ответы на входящие) за последние часы —
|
||
в память тика. Раньше память разговора = только входящие + наши ответы:
|
||
итог задачи/уведомление/предложение, на которое Alex потом отвечает,
|
||
из памяти выпадали."""
|
||
if not chat_ids:
|
||
return []
|
||
since = (datetime.now(timezone.utc) - timedelta(hours=hours)).isoformat()
|
||
replies = {(m.get("reply_text") or "")[:200] for m in replied_hist}
|
||
out = []
|
||
try:
|
||
db = sqlite3.connect(f"file:{es.DB_PATH}?mode=ro", uri=True, timeout=30)
|
||
try:
|
||
rows = db.execute(
|
||
"SELECT timestamp, data FROM events WHERE event_type='OutboundMessage' "
|
||
"AND timestamp > ? ORDER BY id DESC LIMIT 200", (since,)).fetchall()
|
||
finally:
|
||
db.close()
|
||
except Exception:
|
||
return []
|
||
for ts, d in rows:
|
||
d = json.loads(d)
|
||
t = d.get("text") or ""
|
||
if d.get("chat_id") not in chat_ids or t[:200] in replies or t.startswith("(сводный"):
|
||
continue
|
||
out.append(f"- [{ts[5:16].replace('T', ' ')}] {t[:800]}")
|
||
if len(out) >= cap:
|
||
break
|
||
return list(reversed(out))
|
||
|
||
|
||
def _is_stub_digest(d: dict) -> bool:
|
||
"""Заглушка дайджеста (тихий/занятый такт без LLM) — не выжимка."""
|
||
t = (d.get("text") or "").lstrip()
|
||
return bool(d.get("stub")) or t.startswith(("[gate:quiet]", "[absorbed", "[busy")) \
|
||
or "LLM не запускался" in t
|
||
|
||
|
||
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_<LEVEL>) —
|
||
страховка: тик не превращается только в исполнение задач,
|
||
дайджест уровня остаётся его работой;
|
||
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 []
|
||
from . import trust as _trust
|
||
ready = [t for t in (model.get("tasks") or {}).values()
|
||
if t.get("status") == "ready" and t.get("size") in classes
|
||
and _trust.level_serves(level_cfg.name, _trust.norm(t.get("circle")))]
|
||
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
|
||
if ready:
|
||
# один круг на батч: задача правления рядом с задачей владельца
|
||
# снизила бы права всего тика (taint) — круги не смешиваем
|
||
c0 = _trust.norm(ready[0].get("circle"))
|
||
ready = [t for t in ready if _trust.norm(t.get("circle")) == c0]
|
||
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")
|
||
|
||
|
||
# медиа с содержимым ждут ответа и без подписи: фото/документ/видео без
|
||
# текста раньше выпадали из триажа — висели до 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
|
||
from .addressing import is_group
|
||
if is_group(m) 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)
|
||
|
||
|
||
# t-261017110430: какие события EventStore показывать в контексте тика.
|
||
# Шум (Tool*/BudgetSpent/CycleTicked/DigestWritten/GateSkipped/ReflexObserved)
|
||
# не берём — это 90% стора и оно не про «что произошло», а про механику.
|
||
_EVENTS_CTX_TYPES = (
|
||
"MessageReceived", "ReplySent",
|
||
"TaskSubmitted", "TaskCompleted", "TaskClaimed", "TaskPromoted",
|
||
"EscalationRaised", "EscalationAcknowledged",
|
||
"AumMeasured", "PortalMcpCalled", "DeliverySent",
|
||
"ArtifactDelivered", "DecisionMade", "CoreLabel",
|
||
)
|
||
# человекочитаемая выжимка события: одной строкой, без сырого JSON
|
||
_EVENTS_CTX_LABEL = {
|
||
"MessageReceived": "входящее",
|
||
"ReplySent": "ответ в чат",
|
||
"TaskSubmitted": "новая задача",
|
||
"TaskCompleted": "задача закрыта",
|
||
"TaskClaimed": "задача взята",
|
||
"TaskPromoted": "перевод задачи",
|
||
"EscalationRaised": "эскалация",
|
||
"EscalationAcknowledged": "эскалация закрыта",
|
||
"AumMeasured": "замер AUM",
|
||
"PortalMcpCalled": "портал-вызов",
|
||
"DeliverySent": "доставка в чат",
|
||
"ArtifactDelivered": "артефакт доставлен",
|
||
"DecisionMade": "решение",
|
||
"CoreLabel": "ядро: метка",
|
||
}
|
||
|
||
|
||
def _events_digest(limit: int = 12, lookback_min: int = 90) -> list:
|
||
"""Свежие содержательные события стора — компактные строки для контекста.
|
||
|
||
Возвращает СТАРЫЕ→НОВЫЕ (хронология читается естественно). Пустой список —
|
||
событий подходящего типа не было. Ошибки глотаются на месте вызова
|
||
(build_context): контекст не должен падать из-за выжимки.
|
||
"""
|
||
db = es._conn()
|
||
try:
|
||
since = (_now() - timedelta(minutes=lookback_min)).isoformat()
|
||
rows = db.execute(
|
||
"SELECT timestamp, aggregate, aggregate_id, event_type, data "
|
||
"FROM events WHERE event_type IN (%s) AND timestamp > ? "
|
||
"ORDER BY id DESC LIMIT ?" % ",".join("?" * len(_EVENTS_CTX_TYPES)),
|
||
(*_EVENTS_CTX_TYPES, since, limit),
|
||
).fetchall()
|
||
finally:
|
||
db.close()
|
||
out = []
|
||
for r in reversed(rows):
|
||
try:
|
||
d = json.loads(r["data"] or "{}")
|
||
except Exception:
|
||
d = {}
|
||
et = r["event_type"]
|
||
lbl = _EVENTS_CTX_LABEL.get(et, et)
|
||
aid = r["aggregate_id"] or ""
|
||
# приоритет полей: у каждого типа — своё «о чём это».
|
||
# DecisionMade с purpose=addressing — механика адресации групп, не
|
||
# контент: показываем «адресация → выбор», а не пустоту.
|
||
what = (d.get("answer") or d.get("text") or d.get("title")
|
||
or d.get("reason") or d.get("note") or d.get("tool")
|
||
or d.get("result") or "")
|
||
what = str(what).replace("\n", " ").strip()
|
||
if et == "DecisionMade" and d.get("purpose") == "addressing":
|
||
continue # механика адресации групп — не контент, пропускаем
|
||
if et == "DecisionMade":
|
||
what = what or str(d.get("choice") or "?")
|
||
if et == "TaskClaimed" and not what:
|
||
what = f"уровнем {d.get('level', '?')}"
|
||
if et == "AumMeasured":
|
||
what = f"AUM={d.get('aum', '?')} у.е., burn 7д={d.get('burn_7d', '?')}"
|
||
ts = (r["timestamp"] or "")[11:16] # HH:MM
|
||
out.append(f"{ts} {lbl} [{aid[:28]}]: {what[:140]}")
|
||
return out
|
||
|
||
|
||
def build_context(level_cfg, model: dict, batch=None):
|
||
"""Сборка контекста тика: мандат + память уровня + выжимки нижних + батч задач.
|
||
|
||
batch — список задач-словарей (проекции) этого тика; одиночная задача
|
||
(legacy) передаётся списком из одного элемента.
|
||
"""
|
||
parts = [level_cfg.mandate]
|
||
|
||
# динамическая память (harness/memory.py): глубина D0–D3 по входам тика,
|
||
# потолок — круг доверия; индекс догоняет стор перед поиском
|
||
depth, mem_circle = 1, 1
|
||
try:
|
||
from . import memory as _memory, trust as _trust
|
||
_inb = _pending_inbox(model)
|
||
_bat = batch if batch is not None else []
|
||
mem_circle = _trust.tick_circle(model, _inb, _bat)
|
||
depth, _why = _memory.choose_depth(level_cfg.name, _inb, _bat, mem_circle)
|
||
if depth >= 2 and _memory.enabled():
|
||
_memory.sync()
|
||
except Exception as e:
|
||
_log(f"memory: глубина/синк не удались ({str(e)[:120]}) — D1")
|
||
depth = 1
|
||
|
||
# идентичность/цели 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 _is_stub_digest(d):
|
||
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))
|
||
|
||
# t-261017110430: свежие события EventStore — «что происходило в харнесе
|
||
# между тиками». Вкл/выкл — policy.events_context (cli.py policy
|
||
# --events-context on|off); по умолчанию off (сырой шум в тик не подаём).
|
||
if policy and str(policy.get("events_context", "off")).lower() == "on":
|
||
try:
|
||
ev_lines = _events_digest()
|
||
if ev_lines:
|
||
parts.append(
|
||
"СОБЫТИЯ ХАРНЕСА (EventStore, последние ~90 мин, старые→новые; "
|
||
"это уже произошло — не отвечай на них, используй как фон):\n"
|
||
+ "\n".join(ev_lines))
|
||
except Exception as e:
|
||
_log(f"events-context: выжимка событий не собрана ({str(e)[:120]})")
|
||
|
||
# инбокс: входящие 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]
|
||
# ответ на НАШЕ сообщение: полный его текст, а не 300
|
||
# символов из Telegram (аудит 2026-10-17: «Ок делай»
|
||
# на итог рефлексии — тик не видел, что именно делать)
|
||
+ (f"\n ↳ ЭТО ОТВЕТ НА ТВОЁ СООБЩЕНИЕ (полный текст):\n "
|
||
+ m["reply_to_full"][:3000].replace("\n", "\n ")
|
||
if m.get("reply_to_full") else ""))
|
||
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 depth == 0:
|
||
replied_hist = [] # D0: односложные реплики — память разговора не нужна
|
||
own = _own_messages(chat_ids, replied_hist) if depth >= 1 else []
|
||
if own:
|
||
parts.append("ТВОИ НЕДАВНИЕ СООБЩЕНИЯ В ЭТОТ ЧАТ (сам отправил: итоги задач, "
|
||
"уведомления, предложения — Alex может отвечать на них):\n"
|
||
+ "\n".join(own))
|
||
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 <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))
|
||
|
||
if depth >= 2:
|
||
try:
|
||
from . import memory as _memory
|
||
block = _memory.context_block(depth, level_cfg.name, _pending_inbox(model),
|
||
batch or [], mem_circle)
|
||
if block:
|
||
parts.append(block)
|
||
except Exception as e:
|
||
_log(f"memory: блок памяти не собран ({str(e)[:120]})")
|
||
|
||
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"Срез лестницы по запросу: /лестница"
|
||
)
|
||
|
||
|
||
# Эскалация недоделанных (аудит 2026-10-17): «бюджет исчерпан»/смерть воркера
|
||
# возвращали задачу с тем же размером — мин-уровень брал её снова и снова
|
||
# с нуля (рекорд: S-задача 33 захвата за 2 ч; 45 из 272 задач ≥ 2 захватов
|
||
# на min; пустые мин-тики по 0.45M токенов — те же grep по кругу). После
|
||
# SIMBIOT_TASK_ATTEMPTS_PER_LEVEL (2) захватов на уровне задача растёт до
|
||
# размера, которого этот уровень не берёт (S→M: к часу, бюджет 300 шагов).
|
||
_SIZES = ("S", "M", "L")
|
||
|
||
|
||
def _attempts_at(task_id: str, level: str) -> int:
|
||
"""Число захватов задачи уровнем. Best effort: сбой счётчика не должен
|
||
ломать возврат задачи в очередь (он идёт и из обработки падения воркера)
|
||
— тогда 0, т.е. обычный requeue, как до эскалации."""
|
||
try:
|
||
db = sqlite3.connect(es.DB_PATH, timeout=30)
|
||
try:
|
||
return db.execute(
|
||
"SELECT COUNT(*) FROM events WHERE aggregate='task' AND aggregate_id=? "
|
||
"AND event_type='TaskClaimed' AND json_extract(data,'$.level')=?",
|
||
(task_id, level)).fetchone()[0]
|
||
finally:
|
||
db.close()
|
||
except Exception:
|
||
return 0
|
||
|
||
|
||
def _core_task_fit(batch: list) -> list:
|
||
"""Ядро Коди, голова task_fit: S-задача перед ПЕРВЫМ мин-захватом —
|
||
прогноз «влезет ли в мин-тик». shadow — только CorePrediction; on —
|
||
уверенное «не влезет» сразу S→M (к часу), минуя пустые мин-попытки."""
|
||
try:
|
||
from . import kodi_core
|
||
except Exception:
|
||
return batch
|
||
keep = []
|
||
for t in batch:
|
||
rec = None
|
||
if t.get("size") == "S" and _attempts_at(t["id"], "min") == 0:
|
||
try:
|
||
rec = kodi_core.task_shadow(t, "min")
|
||
except Exception:
|
||
rec = None
|
||
if rec and rec.get("act"):
|
||
why = (f"ядро Коди: не влезет в мин-тик, rule:ext (внешние системы, "
|
||
f"ext_hits={rec.get('ext_hits')})") if rec.get("route") == "rule:ext" \
|
||
else f"ядро Коди: не влезет в мин-тик (p_fit={rec.get('p_fit')})"
|
||
es.append_event("task", t["id"], "TaskPromoted", {
|
||
"to": "M", "from": "S", "reason": why + " — сразу к часу"})
|
||
_log(f"core: {t['id']} S→M до мин-попытки ({rec.get('route')}, "
|
||
f"p_fit={rec.get('p_fit')}, ext={rec.get('ext_hits')})")
|
||
continue
|
||
keep.append(t)
|
||
return keep
|
||
|
||
|
||
_UNDER_RE = __import__("re").compile(
|
||
r"не\s+помню|не\s+нашёл|не\s+нашел|нет\s+(данных|в\s+памяти|информации)|"
|
||
r"не\s+знаю,?\s+(что|о\s+чём)|не\s+вижу\s+(в\s+контексте|истори)", __import__("re").I)
|
||
|
||
|
||
def _depth_record(level_cfg, trace_id, text):
|
||
"""DepthChosen: решение о глубине + признаки нехватки памяти по трассе
|
||
тика (поиск по базе, копание в своём сторе/дайджестах, «не помню») —
|
||
сырьё головы ядра «depth»."""
|
||
try:
|
||
from . import memory as _memory
|
||
ch = _memory.last_choice(level_cfg.name)
|
||
if not ch or not trace_id:
|
||
return
|
||
db = sqlite3.connect(f"file:{es.DB_PATH}?mode=ro", uri=True, timeout=30)
|
||
try:
|
||
calls = [json.loads(d) for (d,) in db.execute(
|
||
"SELECT data FROM events WHERE event_type='ToolCalled' "
|
||
"AND json_extract(data,'$.trace_id')=?", (trace_id,))]
|
||
finally:
|
||
db.close()
|
||
rag = sum(1 for c in calls if c.get("tool") == "rag_lookup")
|
||
dig = sum(1 for c in calls if c.get("tool") == "shell" and __import__("re").search(
|
||
r"harness\.db|cli\.py (events|tg-list|escalations)|docs/digests|sqlite3", c.get("input") or ""))
|
||
under = bool(rag or dig or _UNDER_RE.search(text or ""))
|
||
es.append_event("trace", f"depth:{trace_id}", "DepthChosen", {
|
||
"level": level_cfg.name, "trace_id": trace_id, "depth": ch["depth"],
|
||
"reasons": ch.get("reasons"), "circle": ch.get("circle"), "hits": ch.get("hits"),
|
||
"query": ch.get("query"), "rag_calls": rag, "self_digging": dig, "under": under})
|
||
except Exception:
|
||
pass # трасса — best effort
|
||
|
||
|
||
def _trust_followup(trace_id, batch, circle):
|
||
"""Страж отказал в тике с задачами круга ≥ 2 → владельцу кнопки
|
||
«разрешить полные права» по каждой такой задаче (раз на задачу)."""
|
||
if not trace_id or circle is None or circle < 2:
|
||
return
|
||
try:
|
||
db = sqlite3.connect(f"file:{es.DB_PATH}?mode=ro", uri=True, timeout=30)
|
||
try:
|
||
rows = db.execute("SELECT data FROM events WHERE event_type='TrustDenied' "
|
||
"AND aggregate_id=?", (f"trust-deny:{trace_id}",)).fetchall()
|
||
finally:
|
||
db.close()
|
||
if not rows:
|
||
return
|
||
why = json.loads(rows[0][0]).get("reason") or "права круга"
|
||
from . import trust as _trust
|
||
for t in batch or []:
|
||
if _trust.norm(t.get("circle")) >= 2:
|
||
_trust.ask_owner_elevation(t, why)
|
||
except Exception as e:
|
||
_log(f"trust: кнопка владельцу не ушла ({e})")
|
||
|
||
|
||
def requeue_or_escalate(task_id: str, level_cfg, size: str, reason: str) -> str:
|
||
"""Вернуть недоделанную задачу в очередь; после N попыток на уровне —
|
||
поднять размер выше классов уровня. Возвращает новый размер."""
|
||
try:
|
||
limit = int(os.environ.get("SIMBIOT_TASK_ATTEMPTS_PER_LEVEL", "2"))
|
||
except ValueError:
|
||
limit = 2
|
||
new = size
|
||
if size in _SIZES and _attempts_at(task_id, level_cfg.name) >= limit:
|
||
bigger = [z for z in _SIZES[_SIZES.index(size) + 1:] if z not in level_cfg.task_classes]
|
||
if bigger:
|
||
new = bigger[0]
|
||
reason = (f"эскалация: {limit}+ попыток на уровне {level_cfg.name} не хватило "
|
||
f"бюджета — {size}→{new}, возьмёт уровень выше; " + reason)
|
||
es.append_event("task", task_id, "TaskPromoted", {"to": new, "from": size, "reason": reason})
|
||
if new != size:
|
||
_log(f"task {task_id}: {size}→{new} после попыток на {level_cfg.name}")
|
||
return new
|
||
|
||
|
||
def _guard_budget_stub_requeue(claimed, text: str, level_cfg=None, batch=None):
|
||
"""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 []):
|
||
if level_cfg is not None:
|
||
if not _task_still_active(tid):
|
||
_log(f"guard: requeue {tid} пропущен — уже не running")
|
||
continue
|
||
size = next((t.get("size", "S") for t in (batch or []) if t.get("id") == tid), "S")
|
||
requeue_or_escalate(tid, level_cfg, size, reason)
|
||
continue
|
||
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]
|
||
if level_cfg.name == "min":
|
||
batch = _core_task_fit(batch)
|
||
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)
|
||
# круг доверия тика (harness/trust.py): самый недоверенный вход контекста
|
||
# (входящие + задачи батча) — права всего тика; страж — в воркере
|
||
try:
|
||
from . import trust as _trust
|
||
circle = _trust.tick_circle(model, _pending_inbox(model), batch)
|
||
prompt += "\n\n" + _trust.mandate_line(circle)
|
||
except Exception as e:
|
||
_log(f"trust: круг тика не определён ({e}) — тик с правами по умолчанию")
|
||
circle = None
|
||
# Индикация «взял и работаю» (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}",
|
||
circle=circle,
|
||
)
|
||
_trust_followup(trace_id, batch, circle)
|
||
_depth_record(level_cfg, trace_id, text)
|
||
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")
|
||
requeue_or_escalate(tid, level_cfg, size,
|
||
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, level_cfg, batch)
|
||
# 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 kodi_core
|
||
kodi_core.train_all(log=_log)
|
||
kodi_core.reflect(log=_log)
|
||
kodi_core.weekly_report()
|
||
except Exception as e:
|
||
_log(f"core: ночная тренировка не удалась ({e})")
|
||
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}) — групповые ждут следующего такта")
|
||
# ядро Коди: неявная обратная связь чата → метки (не чаще раза в 5 мин)
|
||
if level_cfg.name == "min" and time.time() - _CORE_FB["ts"] >= 300:
|
||
_CORE_FB["ts"] = time.time()
|
||
try:
|
||
from . import kodi_core
|
||
kodi_core.harvest_feedback(log=_log)
|
||
except Exception as e:
|
||
_log(f"core: сбор обратной связи не удался ({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))
|
||
try: # круги доверия: стартовые (владелец — 1, правление — 2), один раз
|
||
from . import trust as _trust
|
||
n = _trust.bootstrap()
|
||
if n:
|
||
_log(f"trust: заведено стартовых кругов {n}")
|
||
except Exception as e:
|
||
_log(f"trust: стартовые круги не заведены ({e})")
|
||
# 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() |