feat(inbox): батч-триаж входящих TG — сводный ответ закрывает все сообщения за один тик (t-260930064924)

Минутный тик отвечал по одному сообщению за тик (m-516173222..228 —
7 тиков подряд на один разговор): контекст показывал все, но мандат
говорил «ответь на него», и главное — нечем было закрыть несколько
входящих одним ответом (tg-reply закрывает одно, «хвосты» снова будили
гейт важности).

- tg_inbox.send_batch_reply: одна TG-отправка → ReplySent на КАЖДОЕ
  входящее батча (полный текст у головного, у хвостовых — ссылка);
  гонки (replied/ignored выпадают, пустой батч → ValueError), разные
  чаты делят батч — каждому своя отправка
- cli tg-reply <id1>,<id2>,… -c: батч-режим; несколько id без -c — отказ
  (защита от случайного закрытия); одиночный режим обратно совместим
- build_context: секция инбокса = «БАТЧ из N» с готовой командой
  (все id через запятую) и инструкцией закрыть ВСЁ за этот тик; кап
  10 → 50 (показываем весь батч, а не хвост)
- мандат min: батч-семантика (сводный ответ по смыслу разговора)
- тесты tests/test_inbox_batch.py: 32 проверки (hermetic), все зелёные
This commit is contained in:
Simbiot Harness 2026-09-30 10:12:47 +00:00
parent e8f18dd7c0
commit 06be288657
6 changed files with 335 additions and 10 deletions

View file

@ -52,6 +52,8 @@ Strands Harness SDK как исполнительное ядро, модели
./venv/bin/python cli.py escalations --open # открытые эскалации ./venv/bin/python cli.py escalations --open # открытые эскалации
./venv/bin/python cli.py escalation-ack <esc-id> -n "что сделал" # закрыть ./venv/bin/python cli.py escalation-ack <esc-id> -n "что сделал" # закрыть
./venv/bin/python cli.py tg-reply <msg-id> "текст" # ответ на входящее TG ./venv/bin/python cli.py tg-reply <msg-id> "текст" # ответ на входящее TG
# батч-режим (t-260930064924): один сводный ответ закрывает несколько входящих
./venv/bin/python cli.py tg-reply m-1,m-2,m-3 -c "текст" # ReplySent на каждое, одна отправка
``` ```
## Автодоставка артефактов (t-260929205927) ## Автодоставка артефактов (t-260929205927)
@ -88,6 +90,14 @@ journalctl -u simbiot-harness -f
отвечает `cli.py tg-reply` (Telegram-отправка + `ReplySent` закрывает входящее). отвечает `cli.py tg-reply` (Telegram-отправка + `ReplySent` закрывает входящее).
Белый список чатов в `.env` (`SIMBIOT_TG_ALLOWED`), чужие → `MessageIgnored`. Белый список чатов в `.env` (`SIMBIOT_TG_ALLOWED`), чужие → `MessageIgnored`.
Батч-триаж (t-260930064924): минутный тик видит ВСЕ неотвеченные сообщения
как один батч (секция инбокса в контексте, кап 50) и обязан закрыть их за
ОДИН тик — сводным ответом `cli.py tg-reply <id1>,<id2>,… -c "текст"`
(одна TG-отправка, `ReplySent` на каждое входящее; полный текст хранится у
головного, у хвостовых — ссылка) или отдельными реплаями за тот же тик.
Гейт важности после сводного ответа больше не будит минуту на «хвосты».
Раньше m-516173222..228 обрабатывались 7 тиками подряд — по одному за тик.
## Без-LLM команды бота (t-260929210755) ## Без-LLM команды бота (t-260929210755)
`/лестница` `/статус` `/задачи` `/помощь` — мгновенные ответы из read model: `/лестница` `/статус` `/задачи` `/помощь` — мгновенные ответы из read model:
их закрывает **секундный рефлекс** (`harness/tg_commands.py` в `_reflex_tick`), их закрывает **секундный рефлекс** (`harness/tg_commands.py` в `_reflex_tick`),

13
cli.py
View file

@ -34,8 +34,11 @@ def main():
p_pr.add_argument("-r", "--reason", default="manual promote") p_pr.add_argument("-r", "--reason", default="manual promote")
p_tgr = sub.add_parser("tg-reply", help="ответить на входящее TG-сообщение") p_tgr = sub.add_parser("tg-reply", help="ответить на входящее TG-сообщение")
p_tgr.add_argument("msg_id", help="id входящего (m-NNN)") p_tgr.add_argument("msg_id", help="id входящего (m-NNN), для батча — несколько через запятую")
p_tgr.add_argument("text") p_tgr.add_argument("text")
p_tgr.add_argument("-c", "--close", action="store_true",
help="батч-режим: один сводный ответ закрывает все указанные входящие "
"(t-260930064924)")
p_taw = sub.add_parser("task-await", help="пометить задачу: результат доставить в TG при готовности") p_taw = sub.add_parser("task-await", help="пометить задачу: результат доставить в TG при готовности")
p_taw.add_argument("task_id") p_taw.add_argument("task_id")
@ -127,6 +130,14 @@ def main():
if low in ("/лестница", "/ladder", "/статус", "/status", "лестница", "status"): if low in ("/лестница", "/ladder", "/статус", "/status", "лестница", "status"):
# срез лестницы по запросу из TG — видимостью управляет сам харнес # срез лестницы по запросу из TG — видимостью управляет сам харнес
args.text = "📊 " + tg_inbox.ladder_snapshot() args.text = "📊 " + tg_inbox.ladder_snapshot()
ids = [x.strip() for x in args.msg_id.split(",") if x.strip()]
if len(ids) > 1 or (args.close and len(ids) == 1):
if not args.close:
print("ERROR: несколько id без -c/--close — укажите флаг для сводного батч-ответа")
sys.exit(1)
mid = tg_inbox.send_batch_reply(ids, args.text)
print(f"OK: сводный батч-ответ отправлен (голова {mid}, закрыто {len(ids)})")
else:
mid = tg_inbox.send_reply(args.msg_id, args.text) mid = tg_inbox.send_reply(args.msg_id, args.text)
print(f"OK: ответ на {mid} отправлен") print(f"OK: ответ на {mid} отправлен")
elif args.cmd == "tg-transcribe": elif args.cmd == "tg-transcribe":

View file

@ -45,10 +45,15 @@ LEVELS = {
name="min", label="Минута · пульс", every=MIN, uses_llm=True, name="min", label="Минута · пульс", every=MIN, uses_llm=True,
model="auto", effort="low", ctx_tokens=4_000, task_classes=("S",), model="auto", effort="low", ctx_tokens=4_000, task_classes=("S",),
mandate=("Ты — минутный пульс харнеса Simbiot. Триаж входящих TG-сообщений: " mandate=("Ты — минутный пульс харнеса Simbiot. Триаж входящих TG-сообщений: "
"если во ВХОДЯЩИХ есть сообщение без ответа — ответь на него командой " "все неотвеченные сообщения приходят тебе ОДНИМ батчем (секция "
"cli.py tg-reply <id> \"текст\" (тон: кратко, по-русски, тёпло; можно " "входящих в контексте) — обработай его ЦЕЛИКОМ за этот тик: "
"выполнить просьбу инструментами и ответить результатом). Быстрые " "сгруппируй в один сводный ответ по смыслу всего разговора "
"мелкие фиксы (S-задачи до 5 минут) тоже твои. Не растекайся. " "(готовая команда — в заголовке секции, tg-reply <id1>,<id2>,… -c "
"\"текст\" закрывает все сразу) или сделай отдельные реплаи, но НИ "
"ОДНО сообщение не должно остаться без ответа на следующий тик. "
"Тон: кратко, по-русски, тёпло; "
"можно выполнить просьбу инструментами и ответить результатом. "
"Быстрые мелкие фиксы (S-задачи до 5 минут) тоже твои. Не растекайся. "
"У тебя есть инструмент rag_lookup — семантический поиск по базе " "У тебя есть инструмент rag_lookup — семантический поиск по базе "
"знаний кооператива (контекст, проекты, порталы, встречи, документы). " "знаний кооператива (контекст, проекты, порталы, встречи, документы). "
"Если вопрос про кооператив/продукты/документы — сначала rag_lookup " "Если вопрос про кооператив/продукты/документы — сначала rag_lookup "

View file

@ -212,9 +212,12 @@ def build_context(level_cfg, model: dict, task=None):
and not tg_commands.parse(m.get("text") or "") and not tg_commands.parse(m.get("text") or "")
and (m.get("text") or m.get("kind") in ("voice", "audio", "video_note"))] and (m.get("text") or m.get("kind") in ("voice", "audio", "video_note"))]
if inbox: if inbox:
inbox = sorted(inbox, key=lambda x: x.get("ts") or "")[-50:] # кап: не хвост, а ВЕСЬ батч
lines = [] lines = []
for m in sorted(inbox, key=lambda x: x.get("ts") or "")[-10:]: ids = []
for i, m in enumerate(inbox, 1):
m_id = "m-" + str(m.get("update_id")) 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") who = m.get("username") or m.get("first_name") or m.get("user_id")
tag = "" tag = ""
if m.get("kind") == "voice": if m.get("kind") == "voice":
@ -224,8 +227,20 @@ def build_context(level_cfg, model: dict, task=None):
tag = (" [голосовое, расшифровка не удалась — попроси прислать текстом]" tag = (" [голосовое, расшифровка не удалась — попроси прислать текстом]"
if m.get("transcribed") else if m.get("transcribed") else
" [голосовое, ещё не расшифровано]") " [голосовое, ещё не расшифровано]")
lines.append(f"- [{m_id}] от {who}{tag}: {m.get('text','')[:300]}") lines.append(f"{i}. [{m_id}] от {who}{tag}: {m.get('text','')[:300]}")
parts.append("ВХОДЯЩИЕ СООБЩЕНИЯ (ответь через: ./venv/bin/python cli.py tg-reply <id> \"текст\"):\n" + "\n".join(lines)) 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))
# доска задач # доска задач
tasks = [t for t in model["tasks"].values() if t["status"] in ("ready", "running")] tasks = [t for t in model["tasks"].values() if t["status"] in ("ready", "running")]

View file

@ -141,6 +141,60 @@ def send_reply(msg_id: str, text: str, timeout: int = 35):
return msg_id return msg_id
def send_batch_reply(msg_ids: list, text: str, timeout: int = 35):
"""Сводный ответ на БАТЧ входящих (t-260930064924): одна Telegram-отправка
закрывает несколько сообщений одного разговора.
Минутный тик собирает ВСЕ неотвеченные сообщения разом (секция инбокса)
и отвечает одним сводным текстом — вместо по одному за тик
(m-516173222..228 обрабатывались 7 тиками подряд). Событие ReplySent
пишем на КАЖДОЕ закрываемое входящее (проекция ставит replied=True) —
сводный ответ не должен оставлять «хвостов», на которые гейт важности
потом будит минуту снова. Полный текст храним у головного (первого по
ts), у остальных — ссылку на него, чтобы не дублировать текст в сторе.
Сообщения разных чатов не смешиваются: батч делится по chat_id, каждому
чату — своя отправка (сводный текст один, белый список — не только личка).
Идемпотентность: перед отправкой перечитываем read model; уже отвеченные
(гонка с сек-рефлексом/ручным tg-reply) выпадают из батча. Если выпали
все — ValueError (тихий пропуск, не ошибка тика).
"""
model = es.run_projections()
inbox = model.get("inbox") or {}
pending = []
for mid in msg_ids:
m = inbox.get(mid)
if m is None:
raise ValueError(f"входящее {mid} не найдено")
if m.get("replied") or m.get("ignored"):
continue # гонка: уже закрыто/чужое — не трогаем
pending.append((mid, m))
if not pending:
raise ValueError("на все сообщения батча уже отвечено")
_load_env()
head_mid = pending[0][0]
batch_id = f"b-{pending[0][1].get('update_id')}-{len(pending)}"
by_chat = {}
for mid, m in pending:
by_chat.setdefault(m["chat_id"], []).append((mid, m))
for chat_id, items in by_chat.items():
try: # индикатор — best effort, как в send_reply
_api("sendChatAction", {"chat_id": chat_id, "action": "typing"}, timeout=10)
except Exception:
pass
r = send_message(chat_id, text, reply_to=None, timeout=timeout)
for mid, m in items:
es.append_event("inbox", mid, "ReplySent", {
"to_chat": chat_id,
"text": (text if mid == head_mid
else f"(сводный ответ батча {batch_id} — см. {head_mid})")[:2000],
"tg_ok": bool(r.get("ok")),
"batch": {"id": batch_id, "size": len(pending), "head": head_mid},
})
return head_mid
def poll_loop(): def poll_loop():
"""Long polling getUpdates → MessageReceived. Отдельный процесс.""" """Long polling getUpdates → MessageReceived. Отдельный процесс."""
_load_env() _load_env()

230
tests/test_inbox_batch.py Normal file
View file

@ -0,0 +1,230 @@
# -*- coding: utf-8 -*-
"""t-260930064924: батч-обработка входящих TG-сообщений (hermetic, без LLM/TG).
Запуск: ./venv/bin/python tests/test_inbox_batch.py
Минутный тик отвечает на ВСЕ неотвеченные сообщения одним сводным
ответом (или несколькими реплаями за тик), а не по одному за тик
(m-516173222..228 — 7 тиков подряд на один разговор).
Проверяет: батч-секция build_context (все сообщения + готовая команда),
send_batch_reply (одна отправка → ReplySent на каждое; гонки; ignored;
разные чаты), cli tg-reply -c (батч-режим; отказ без флага; совместимость
одиночного режима), гейт после батча, отсутствие дублей ReplySent.
"""
import io
import json
import os
import re
import sys
# ── изоляция: своя БД в /tmp (инбокс пишем сами, prod не нужен) ──
_DST = "/tmp/simbiot-test-inbox-batch.db"
for p in (_DST, _DST + "-wal", _DST + "-shm"):
if os.path.exists(p):
os.unlink(p)
os.environ["SIMBIOT_DB"] = _DST
sys.path.insert(0, "/root/simbiot-harness")
from harness import es, tg_inbox
from harness.scheduler import build_context, collect_signals, LEVELS
fails = []
def check(name, cond, extra=""):
print(("PASS" if cond else "FAIL"), name, ("| " + extra) if not cond else "")
if not cond:
fails.append(name)
def _recv(update_id, text, chat_id=42, date=1780000000):
"""MessageReceived в Telegram-формате payload (chat:{id})."""
tg_inbox.receive_message({"update_id": update_id, "message": {
"message_id": update_id % 100000, "date": date, "chat": {"id": chat_id},
"from": {"id": 1, "username": "alex", "first_name": "Alex"}, "text": text}})
es.init_db()
# ── 1. build_context: батч-секция показывает ВСЕ неотвеченные + готовую команду ──
fake = {"inbox": {}, "tasks": {}, "escalations": {}, "policy": {}}
for i in range(12):
fake["inbox"][f"m-700{i:02d}"] = {
"update_id": 70000 + i, "chat_id": 42, "text": f"сообщение {i} из разговора",
"kind": "text", "replied": False, "ignored": False,
"ts": f"2026-09-30T07:{i:02d}:00+00:00",
}
ctx = build_context(LEVELS["min"], fake)
m = re.search(r"ВХОДЯЩИЕ СООБЩЕНИЯ.*?(?:\n\n|$)", ctx, re.S)
sec = m.group(0) if m else ""
check("build_context: батч-секция есть", bool(sec))
check("build_context: ВСЕ 12 в батче (кап 50, не 10)",
all(f"m-700{i:02d}" in sec for i in range(12)), sec[:200])
check("build_context: счётчик батча", "БАТЧ из 12" in sec, sec[:150])
ids_line = ",".join(f"m-700{i:02d}" for i in range(12))
check("build_context: ГОТОВАЯ команда со ВСЕМИ id",
f"cli.py tg-reply {ids_line} -c" in sec, sec[:400])
check("build_context: инструкция «за ЭТОТ тик»", "именно в ЭТОМ тике" in sec)
# ── 2. send_batch_reply: одна отправка → ReplySent на каждое входящее ──
# сценарий из постановки: 7 сообщений одного разговора об экономии тиков
for i, text in enumerate([
"думаешь, стоит экономить тики?", "или не париться?", "просто интересно",
"сколько мы уже потратили", "на тики за сегодня", "примерно",
"ну и на неделю если считать",
]):
_recv(800 + i, text, date=1780000000 + i)
sends, actions = [], []
tg_inbox.send_message = lambda chat_id, text, reply_to=None, timeout=35: (
sends.append((chat_id, text)) or {"ok": True})
tg_inbox._api = lambda method, params, timeout=35: (
actions.append((method, params.get("chat_id"))) or {"ok": True})
batch = [f"m-{800+i}" for i in range(7)]
before = es.count_events()
head = tg_inbox.send_batch_reply(batch, "Сводный ответ: экономить стоит, посчитал — 5 тиков за час.")
check("send_batch_reply: возвращает головной id", head == "m-800", head)
check("send_batch_reply: ОДНА отправка TG", len(sends) == 1, str(sends))
check("send_batch_reply: чат правильный", sends and sends[0][0] == 42, str(sends))
check("send_batch_reply: 7 событий ReplySent",
es.count_events() - before == 7, str(es.count_events() - before))
model = es.run_projections()
replied = [model["inbox"][f"m-{800+i}"]["replied"] for i in range(7)]
check("send_batch_reply: проекция закрыла ВСЕ 7", all(replied), str(replied))
head_text = model["inbox"]["m-800"]["reply_text"]
tail_text = model["inbox"]["m-806"]["reply_text"]
check("send_batch_reply: полный текст у головного",
head_text.startswith("Сводный ответ"), head_text[:80])
check("send_batch_reply: у хвостовых — ссылка (нет дубля текста)",
"сводный ответ батча" in tail_text and "см. m-800" in tail_text, tail_text)
# ── 3. гонка и ignored: батч закрывает только живых кандидатов ──
_recv(810, "первое из новой пачки")
_recv(811, "второе")
_recv(812, "третье — доживёт до батча")
# m-810 уже отвечено кем-то раньше (гонка): одиночный ReplySent без батч-поля
es.append_event("inbox", "m-810", "ReplySent",
{"to_chat": 42, "text": "гонка: успел раньше", "tg_ok": True})
# m-811 — спам/чужой: MessageIgnored (проекция ставит ignored=True)
es.append_event("inbox", "m-811", "MessageIgnored", {"reason": "spam"})
sends.clear()
before = es.count_events()
head2 = tg_inbox.send_batch_reply(["m-810", "m-811", "m-812"],
"Сводный по живым сообщениям пачки.")
check("гонка: закрыт только живой кандидат (1 ReplySent)",
es.count_events() - before == 1, str(es.count_events() - before))
check("гонка: одна отправка на батч", len(sends) == 1, str(sends))
model = es.run_projections()
check("гонка: живое закрыто", model["inbox"]["m-812"]["replied"] is True)
check("гонка: голова батча = живое сообщение", head2 == "m-812", head2)
# повтор батча: все уже отвечены → ValueError (тихий пропуск тика)
try:
tg_inbox.send_batch_reply(["m-812"], "повтор")
check("гонка: повтор батча → ValueError", False, "не бросил")
except ValueError as e:
check("гонка: повтор батча → ValueError", "уже отвечено" in str(e), str(e))
# неизвестный id → ValueError с именем
try:
tg_inbox.send_batch_reply(["m-999999"], "нет такого")
check("гонка: неизвестный id → ValueError", False, "не бросил")
except ValueError as e:
check("гонка: неизвестный id → ValueError", "m-999999" in str(e), str(e))
# ── 4. разные чаты: батч делится, каждому — своя отправка ──
_recv(900, "в личке", chat_id=42)
_recv(901, "в другом чате", chat_id=77)
sends.clear()
before = es.count_events()
head3 = tg_inbox.send_batch_reply(["m-900", "m-901"], "Сводный по двум чатам.")
check("чаты: 2 отправки (по одной на чат)", len(sends) == 2, str(sends))
check("чаты: 2 события ReplySent", es.count_events() - before == 2,
str(es.count_events() - before))
chats = sorted(c for c, _ in sends)
check("чаты: каждому свой chat_id", chats == [42, 77], str(sends))
model = es.run_projections()
check("чаты: оба входящих закрыты",
model["inbox"]["m-900"]["replied"] and model["inbox"]["m-901"]["replied"])
# ── 5. гейт: после сводного ответа минуту не будит ──
sigs = collect_signals(LEVELS["min"], model)
check("гейт: unreplied_inbox не будит минуту после батча",
all(s[1] != "unreplied_inbox" for s in sigs), str(sigs))
# ── 6. cli tg-reply: батч-режим ──
import importlib.util
_spec = importlib.util.spec_from_file_location("cli", "/root/simbiot-harness/cli.py")
cli = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(cli)
def _cli(argv):
sys.argv = argv
buf = io.StringIO()
sys.stdout = buf
try:
cli.main()
return 0, buf.getvalue()
except SystemExit as e:
return e.code, buf.getvalue()
finally:
sys.stdout = sys.__stdout__
for i in range(3):
_recv(950 + i, f"cli-сообщение {i}", date=1780000030 + i)
sends.clear()
rc, out = _cli(["cli.py", "tg-reply", "m-950,m-951,m-952", "-c", "Ответ из CLI на батч"])
check("cli: батч через запятую + -c → OK",
rc == 0 and out.startswith("OK: сводный батч-ответ"), f"rc={rc} | {out}")
check("cli: одна отправка, закрыто 3",
len(sends) == 1 and "закрыто 3)" in out, f"{sends} | {out}")
# без -c и несколько id → отказ (защита от случайного суммарного закрытия)
rc, out = _cli(["cli.py", "tg-reply", "m-950,m-951", "текст без флага"])
check("cli: несколько id БЕЗ -c → отказ", rc == 1, f"rc={rc} | {out}")
# одиночный id с -c — валидный батч из 1
_recv(960, "одиночное", date=1780000040)
sends.clear()
rc, out = _cli(["cli.py", "tg-reply", "m-960", "-c", "Одиночный ответ в батч-режиме"])
check("cli: одиночный id + -c → OK",
rc == 0 and out.startswith("OK: сводный батч-ответ"), f"rc={rc} | {out}")
check("cli: одиночный батч закрыл 1", len(sends) == 1 and "закрыто 1)" in out, out)
# старый одиночный режим без флага работает как раньше (совместимость)
_recv(970, "обычное", date=1780000050)
sends.clear()
rc, out = _cli(["cli.py", "tg-reply", "m-970", "Старый одиночный режим"])
check("cli: одиночный режим без флага — совместимость",
rc == 0 and out.startswith("OK: ответ на m-970 отправлен"), f"rc={rc} | {out}")
# ── 7. команды бота не попадают в батч-секцию ──
fake2 = json.loads(json.dumps(fake))
fake2["inbox"]["m-990001"] = {
"update_id": 990001, "chat_id": 42, "text": "/лестница", "kind": "text",
"replied": False, "ignored": False, "ts": "2026-09-30T08:00:00+00:00"}
ctx2 = build_context(LEVELS["min"], fake2)
m2 = re.search(r"ВХОДЯЩИЕ СООБЩЕНИЯ.*?(?:\n\n|$)", ctx2, re.S)
sec2 = m2.group(0) if m2 else ""
check("фильтр: команда бота НЕ в батче", "m-990001" not in sec2 and "990001" not in sec2)
# ── 8. стор: ReplySent по одному на входящее, дублей нет; батчи размечены ──
db = es._conn()
rows = db.execute(
"SELECT aggregate_id, COUNT(*) c FROM events "
"WHERE aggregate='inbox' AND event_type='ReplySent' "
"GROUP BY aggregate_id HAVING c > 1").fetchall()
check("стор: нет дублей ReplySent на входящее", rows == [], str([dict(r) for r in rows]))
batches = db.execute(
"SELECT COUNT(*) c FROM events WHERE aggregate='inbox' AND event_type='ReplySent' "
"AND data LIKE '%\"batch\":%'").fetchone()["c"]
check("стор: батч-события размечены (batch-поле)", batches >= 12, str(batches))
db.close()
print()
if fails:
print("ПРОВАЛЕНО:", fails)
sys.exit(1)
print("ВСЕ ТЕСТЫ ПРОЙДЕНЫ (hermetic, БД:", _DST + ")")