feat(hands,jobs): watchdog ring with a neighbour agent, daily heartbeat and silence alarm

This commit is contained in:
hh committed 2026-09-22 14:21:51 +00:00
1 parent 0475e09c81
commit f6cb17acaf
9 files changed
+1029

No files matched your search

+113
View File
@@ -0,0 +1,113 @@
"""Сторожевое кольцо с чужим агентом: своя отметка и слежка за его молчанием.
Два джоба, оба раз в сутки и оба в отдельной комнате вайбграма (второй
экземпляр клиента, `VIBEGRAM_HB`):
- `beat` печатает `hb <ник> at=… boot=… every=… src=…`. Период не константа:
он выведен из крона ЭТОГО ЖЕ джоба (`run.job.cron`) - что зарегистрировано
у планировщика, то и написано в строке. Не сводится к «раз в N часов» -
пишем `every=unknown`, потому что соврать числом хуже, чем отказаться;
- `watch` читает окно комнаты, сверяет возраст последней отметки соседа с
порогом из ЕГО ЖЕ строки, печатает в комнату `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:
events = await VIBEGRAM_HB.pending(limit=WINDOW)
except Exception: # noqa: BLE001 - любой отказ хаба это отсутствие показания
# Окно не прочитано - показания нет вовсе, и тревога тут запрещена
# ровно так же, как на съехавшем окне: молчание хаба это не молчание
# соседа. Говорим об этом в лог, состояние не трогаем.
_log.exception("сторож: комната не прочитана, прогон без показания")
return
state = Watch.load(STATE_FILE)
report = look(
state,
events,
now=datetime.now(UTC),
res=every_from_cron(run.job.cron, run.scheduler.tz),
)
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()