"""Сторожевое кольцо с чужим агентом: своя отметка и слежка за его молчанием. Два джоба, оба раз в сутки и оба в отдельной комнате вайбграма (второй экземпляр клиента, `VIBEGRAM_HB`): - `beat` печатает `hb <ник> at=… boot=… every=… src=…`. Период не константа: он выведен из крона ЭТОГО ЖЕ джоба (`run.job.cron`) - что зарегистрировано у планировщика, то и написано в строке. Не сводится к «раз в N часов» - пишем `every=unknown`, потому что соврать числом хуже, чем отказаться; - `watch` читает окно - последние `WINDOW` сообщений САМОЙ комнаты, а не «что нового с курсора», - сверяет возраст последней отметки соседа с порогом из ЕГО ЖЕ строки, печатает в комнату `ack` и - если цепь молчит - отдаёт тревогу мастеру через `run.spawn_job`, как это делает триаж вайбграма: будить ли владельца, решает диспетчер. Токена сторожевой комнаты нет - оба джоба молча ничего не делают, а о том, что сторож не сконфигурирован, один раз сказано в логе при старте (`beaver_agent/hands/__init__.py`). """ from __future__ import annotations import logging from datetime import UTC, datetime from typing import TYPE_CHECKING from beaver_agent.hands import VIBEGRAM_HB from beaver_agent.hands.heartbeat import ( BOOT, PEER, WINDOW, Watch, alarm_text, beat_line, every_from_cron, look, ) from beaver_agent.vault import STATE_DIR if TYPE_CHECKING: from beaver_gateway.jobs.scheduler import JobRun _log = logging.getLogger(__name__) STATE_FILE = STATE_DIR / "сторож.json" """Что видели в прошлый прогон: без файла сторож после передеплоя считает, что соседа не видел ни разу, и разоружается сам по себе.""" TITLE = "сторож" BRIEF = ( "Сторожевое кольцо с чужим агентом {peer} (взаимные отметки раз в сутки " "в отдельной комнате вайбграма) подняло тревогу.\n" "{body}\n" 'Будить владельца - inject(conversation="master", urgency="wake", ' "text=до 3 строк: что цепь молчит, сколько молчит и какие три причины). " "Это не авария у нас: наша половина работает, молчит чужая. " "Если inject вернул ошибку - это сбой доставки, а не «не срочно»: " "повтори один раз с тем же текстом." ) async def beat(run: JobRun) -> None: """Отметка о том, что мы живы. Не ушла - джоб падает, это видно в прогонах.""" if VIBEGRAM_HB is None: return line = beat_line( nick=VIBEGRAM_HB.nick, at=datetime.now(UTC), boot=BOOT, every=every_from_cron(run.job.cron, run.scheduler.tz), ) await VIBEGRAM_HB.say(line) _log.info("сторож: отметка ушла - %s", line) async def watch(run: JobRun) -> None: """Прогон сторожа: окно → решение → `ack` в комнату → тревога, если молчит.""" if VIBEGRAM_HB is None: return try: window = await VIBEGRAM_HB.window(WINDOW) except Exception: # noqa: BLE001 - любой отказ хаба это отсутствие показания # Окно не прочитано - показания нет вовсе, и тревога тут запрещена # ровно так же, как на съехавшем окне: молчание хаба это не молчание # соседа. Говорим об этом в лог, состояние не трогаем. _log.exception("сторож: комната не прочитана, прогон без показания") return state = Watch.load(STATE_FILE) report = look( state, window.events, now=datetime.now(UTC), res=every_from_cron(run.job.cron, run.scheduler.tz), full=window.full, ) sent = True try: await VIBEGRAM_HB.say(report.ack) except Exception as exc: # noqa: BLE001 - своя запись не прошла, это различитель sent = False _log.warning("сторож: свой ack в комнату не ушёл: %s", exc) _log.info("сторож: %s | %s", report.ack, report.note) if report.alarm is not None: await run.spawn_job( agent="beaver-triage", title=TITLE, text=BRIEF.format(peer=PEER, body=alarm_text(report, mine_sent=sent)), ) now = datetime.now(UTC) if report.alarm == "silence": state.alerted = now else: state.unarmed_told = True state.save()