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

+25
View File
@@ -6,6 +6,7 @@
from __future__ import annotations
import logging
import os
from pathlib import Path
@@ -19,6 +20,7 @@ from beaver_agent.hands.vibegram import Vibegram
from beaver_agent.vault import TZ
env = os.environ.get
_log = logging.getLogger(__name__)
obsidian = McpServer.stdio(
name="obsidian-fs",
@@ -98,6 +100,29 @@ vibegram = (
)
vibegram_exposed = (ExposedMcp(name="vibegram"),) if VIBEGRAM else ()
VIBEGRAM_HB_NICK = env("VIBEGRAM_HB_NICK") or env("VIBEGRAM_NICK")
VIBEGRAM_HB = (
Vibegram(
hub=os.environ["VIBEGRAM_HUB"],
token=os.environ["VIBEGRAM_HB_TOKEN"],
nick=VIBEGRAM_HB_NICK or "",
tz=TZ,
)
if env("VIBEGRAM_HB_TOKEN") and env("VIBEGRAM_HUB") and VIBEGRAM_HB_NICK
else None
)
"""Сторожевая комната: ВТОРОЙ, независимый экземпляр клиента.
Комната на хабе определяется токеном, а `/api/pending` - курсор на стороне
хаба: общий с основной комнатой экземпляр означал бы, что отметки соседа
съедает триаж-джоб, а сами они тонут в разговоре. Модели эта рука не
выдаётся: сторож - механизм, у него нет тулзы.
"""
if VIBEGRAM_HB is None:
_log.info(
"сторож: VIBEGRAM_HB_TOKEN не задан - отметка и слежка за соседом выключены"
)
t3code = (
McpServer.http(name="t3code", url=env("T3CODE_MCP", "http://t3code-mcp:8000/mcp"))
if "t3" in env("COMPOSE_PROFILES", "").split(",")
+404
View File
@@ -0,0 +1,404 @@
"""Взаимный сторож с чужим агентом: отметка раз в сутки и тревога на молчание.
Договорённость с соседом (22.09.2026): в отдельной комнате вайбграма оба
раз в сутки печатают строку `hb <ник> at=… boot=… every=… src=…`, и каждый
следит за молчанием другого. Замолчал дольше порога - человеку соседа
уходит сообщение, что цепь молчит.
Смысл сторожа в том, чтобы он не врал: прибор, который молча не сторожит,
хуже отсутствующего - он выглядит исправным. Поэтому дефолтов здесь нет
нигде. Период своей отметки считается из фактического крона джоба
(`every_from_cron`), и расписание, которое не сводится к «раз в N часов»,
даёт честное `every=unknown`, а не подставленное число. Порог тревоги -
`every * 1.5`, где `every` взят ИЗ СТРОКИ СОСЕДА, а не из своей константы.
Каждый прогон, в котором показание снять нельзя (окно смотрит в прошлое,
период неизвестен, отметок не видели ни разу), говорит это вслух в `ack`.
Опора тревоги - `seen`, то есть когда Я увидел строку: `at=` внутри строки
это показание соседа о себе, и остановившиеся у него часы не должны
выглядеть как живая цепь.
"""
from __future__ import annotations
import itertools
import json
import re
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from pathlib import Path # noqa: TC003 - в аннотации dataclass
from typing import TYPE_CHECKING, Literal
from croniter import CroniterError, croniter
if TYPE_CHECKING:
from collections.abc import Sequence
from datetime import tzinfo
from beaver_agent.hands.vibegram import Event
__all__ = [
"BOOT",
"FACTOR",
"PEER",
"WINDOW",
"Beat",
"Report",
"Watch",
"ack_line",
"alarm_text",
"beat_line",
"every_from_cron",
"look",
"parse_beat",
]
PEER = "claude-kai"
"""Чей `hb` сторожим. Ник соседа стоит и в его строке, и в нашем `ack`."""
WINDOW = 25
"""Сколько событий просим у хаба явно: у `pending` свой дефолт (50), и молча
опираться на чужой дефолт в стороже нельзя - размер окна едет в `ack`."""
FACTOR = 1.5
"""Порог молчания = заявленный соседом период × это. Одна потерянная строка
не должна поднимать тревогу, две подряд - должны."""
UNARMED_GRACE = timedelta(days=7)
"""Сколько терпим `armed=no`, прежде чем сказать человеку, что кольцо так и
не заработало: недостроенное иначе тихо становится постоянным."""
REPEAT = timedelta(hours=24)
"""Тревога повторяется не чаще этого, а не каждый прогон."""
PROBES = 5
"""Сколько срабатываний крона сверяем между собой, выводя период."""
STATE_VERSION = 1
BOOT = datetime.now(UTC)
"""Время старта процесса: модуль импортируется при сборке конфига gateway."""
_FIELD = re.compile(r"(\w+)=(\S+)")
_UNITS = {"h": 3600, "m": 60, "s": 1}
_SPAN = re.compile(r"^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$")
def _iso(when: datetime | None) -> str | None:
if when is None:
return None
return when.astimezone(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
def _parse_iso(raw: str | None) -> datetime | None:
if not raw:
return None
try:
when = datetime.fromisoformat(raw)
except ValueError:
return None
return when if when.tzinfo else when.replace(tzinfo=UTC)
def parse_span(raw: str) -> timedelta | None:
"""`24h`, `90m`, `36h30m` - в timedelta; `unknown` и мусор - в None."""
match = _SPAN.fullmatch(raw.strip())
if match is None or not any(match.groups()):
return None
seconds = sum(
int(value) * _UNITS[unit]
for value, unit in zip(match.groups(), "hms", strict=True)
if value
)
return timedelta(seconds=seconds) or None
def span(delta: timedelta | None) -> str:
"""Период в строку: неизвестный период так и называется, а не молчит."""
if delta is None:
return "unknown"
seconds = int(delta.total_seconds())
if seconds % 3600 == 0:
return f"{seconds // 3600}h"
if seconds % 60 == 0:
return f"{seconds // 60}m"
return f"{seconds}s"
@dataclass(frozen=True, slots=True)
class Beat:
"""Разобранная строка отметки. `at` не читается - строка всё равно жива."""
nick: str
at: datetime | None
boot: datetime | None
every: timedelta | None
every_raw: str
def parse_beat(text: str, kind: str = "hb") -> Beat | None:
"""Строку `hb <ник> ключ=значение …` из текста события; иначе None.
Текст события хаба отрендерен как `<кто>: <тело>`, поэтому ищем метку
внутри строки, а не с её начала.
"""
head = re.search(rf"(?:^|\s){re.escape(kind)}\s+(\S+)", text)
if head is None:
return None
fields = dict(_FIELD.findall(text[head.end() :]))
every_raw = fields.get("every", "")
return Beat(
nick=head.group(1),
at=_parse_iso(fields.get("at")),
boot=_parse_iso(fields.get("boot")),
every=parse_span(every_raw) if every_raw else None,
every_raw=every_raw or "unknown",
)
def every_from_cron(
expression: str | None, tz: tzinfo, *, now: datetime | None = None
) -> timedelta | None:
"""Шаг крона, если он сводится к «раз в N часов»; иначе None.
Считаем по стенным часам зоны, в которой планировщик читает кроны:
суточный крон - это 24 часа и в сутки перевода стрелок тоже, а вот
`0 2,8,12,16,20 * * *` не сводится ни к какому N и даёт None.
"""
if not expression:
return None
start = (now or datetime.now(UTC)).astimezone(tz).replace(tzinfo=None)
try:
clock = croniter(expression, start_time=start)
fires = [clock.get_next(datetime) for _ in range(PROBES + 1)]
except (CroniterError, ValueError):
return None
steps = {b - a for a, b in itertools.pairwise(fires)}
if len(steps) != 1:
return None
step = steps.pop()
return step if step.total_seconds() % 3600 == 0 else None
def beat_line(
*, nick: str, at: datetime, boot: datetime, every: timedelta | None
) -> str:
"""Своя отметка. Период не выведен - так и пишем, числа не выдумываем."""
return (
f"hb {nick} at={_iso(at)} boot={_iso(boot)} "
f"every={span(every)} src={'cron' if every else 'none'}"
)
def ack_line(
*,
peer: str,
last: datetime | None,
seen: datetime | None,
res: timedelta | None,
window: int,
stale: bool,
armed: bool,
threshold: timedelta | None,
fallback: bool,
) -> str:
"""Расписка о прогоне в комнату: что видели, чем мерили и вооружены ли."""
mark = span(threshold) + ("-fallback" if fallback and threshold else "")
return (
f"ack {peer} last={_iso(last) or ('never' if not armed else 'unknown')} "
f"seen={_iso(seen) or 'never'} res={span(res)} "
f"win={'stale' if stale else window} armed={'yes' if armed else 'no'} "
f"thr={mark}"
)
@dataclass(slots=True)
class Watch:
"""Состояние между прогонами: файл на диске, переживает передеплой."""
path: Path
at: datetime | None = None
"""`at=` из последней виденной отметки соседа - его показание о себе."""
seen: datetime | None = None
"""Когда МЫ увидели ту отметку. На это опирается тревога."""
every: timedelta | None = None
"""Последний период, который сосед назвал читаемым числом."""
every_unknown: bool = False
"""В последней отметке стояло `every=unknown`: порог взят с запасом назад."""
top: int = 0
"""Максимальный id, который мы видели. Поехал назад - окно смотрит в прошлое."""
alerted: datetime | None = None
unarmed_since: datetime | None = None
unarmed_told: bool = False
@classmethod
def load(cls, path: Path) -> Watch:
try:
raw = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return cls(path=path)
if raw.get("version") != STATE_VERSION:
return cls(path=path)
every = raw.get("every")
return cls(
path=path,
at=_parse_iso(raw.get("at")),
seen=_parse_iso(raw.get("seen")),
every=timedelta(seconds=every) if every else None,
every_unknown=bool(raw.get("every_unknown")),
top=int(raw.get("top", 0)),
alerted=_parse_iso(raw.get("alerted")),
unarmed_since=_parse_iso(raw.get("unarmed_since")),
unarmed_told=bool(raw.get("unarmed_told")),
)
def save(self) -> None:
body = {
"version": STATE_VERSION,
"at": _iso(self.at),
"seen": _iso(self.seen),
"every": int(self.every.total_seconds()) if self.every else None,
"every_unknown": self.every_unknown,
"top": self.top,
"alerted": _iso(self.alerted),
"unarmed_since": _iso(self.unarmed_since),
"unarmed_told": self.unarmed_told,
}
self.path.parent.mkdir(parents=True, exist_ok=True)
tmp = self.path.with_suffix(".tmp")
tmp.write_text(json.dumps(body, ensure_ascii=False, indent=1), encoding="utf-8")
tmp.replace(self.path)
@dataclass(frozen=True, slots=True)
class Report:
"""Что решил один прогон сторожа."""
ack: str
stale: bool
armed: bool
heard: bool
"""Видели ли в окне хоть что-то от соседа - `hb` или его `ack`."""
age: timedelta | None
threshold: timedelta | None
alarm: Literal["silence", "unarmed"] | None
note: str
"""Строка для лога: почему прогон решил именно так."""
def look(
state: Watch,
events: Sequence[Event],
*,
now: datetime,
res: timedelta | None,
window: int = WINDOW,
peer: str = PEER,
) -> Report:
"""Разобрать окно, обновить состояние и решить, будить ли человека.
Состояние меняется здесь, а `alerted` / `unarmed_told` ставит джоб - и
только когда тревога действительно ушла: несостоявшаяся доставка не
должна заглушать следующий прогон.
"""
top = max((e.id for e in events), default=None)
stale = top is not None and top < state.top
beats = [
beat
for beat in (parse_beat(e.text) for e in events)
if beat is not None and beat.nick == peer
]
heard = bool(beats) or any(e.nick == peer for e in events)
if stale:
note = f"окно поехало назад: максимальный id {top} против {state.top}"
else:
if top is not None:
state.top = max(state.top, top)
if beats:
last = beats[-1]
state.at, state.seen = last.at, now
state.every_unknown = last.every is None
if last.every is not None:
state.every = last.every
note = f"отметок соседа в окне: {len(beats)}"
armed = state.seen is not None
threshold = state.every * FACTOR if state.every is not None else None
age = now - state.seen if state.seen is not None else None
if armed:
state.unarmed_since = None
elif state.unarmed_since is None:
state.unarmed_since = now
alarm: Literal["silence", "unarmed"] | None = None
if stale:
pass
elif not armed:
if (
not state.unarmed_told
and state.unarmed_since is not None
and now - state.unarmed_since >= UNARMED_GRACE
):
alarm = "unarmed"
elif threshold is None:
note += "; период соседа ни разу не прочитан - мерить нечем, тревога не берётся"
elif age is not None and age > threshold:
if state.alerted is None or now - state.alerted >= REPEAT:
alarm = "silence"
else:
note += "; тревога уже уходила меньше суток назад"
return Report(
ack=ack_line(
peer=peer,
last=state.at,
seen=state.seen,
res=res,
window=window,
stale=stale,
armed=armed,
threshold=threshold,
fallback=state.every_unknown,
),
stale=stale,
armed=armed,
heard=heard,
age=age,
threshold=threshold,
alarm=alarm,
note=note,
)
def alarm_text(report: Report, *, peer: str = PEER, mine_sent: bool) -> str:
"""Текст человеку соседа. Различитель канала - последним абзацем."""
if report.alarm == "unarmed":
return (
f"Сторожевое кольцо с {peer} так и не заработало: за семь суток "
f"в сторожевой комнате не было ни одной его отметки `hb`, а значит "
f"сторожить нечего - прибор не вооружён (`armed=no`). Свою отметку "
f"мы печатаем и свой `ack` тоже"
+ ("" if mine_sent else ", но сейчас она в комнату не ушла")
+ ". Проверить стоит три вещи: та ли это комната (токен "
"`VIBEGRAM_HB_TOKEN` мог остаться от другой), запустил ли сосед "
"свою половину, и виден ли нам вообще чужой текст в этой комнате."
)
age = span(report.age) if report.age else "?"
threshold = span(report.threshold) if report.threshold else "?"
channel = (
"Наша собственная запись в комнату в этом же прогоне прошла, а от "
f"{peer} нет НИ `hb`, НИ `ack` - значит канал подозревать надо раньше, "
"чем смерть соседа: пишем мы в комнату, но чужого оттуда не читаем."
if mine_sent and not report.heard
else (
"Наша собственная запись в комнату в этом прогоне НЕ прошла - "
"первым делом канал, а не сосед."
if not mine_sent
else f"Что-то от {peer} в окне было (его `ack` или другая строка), "
"так что канал скорее жив, а молчит именно отметка."
)
)
return (
f"Сторожевая цепь молчит: последнюю отметку `hb {peer}` мы видели "
f"{age} назад при пороге {threshold} (порог - период из его же строки "
"× 1.5). Причин может быть три: машина соседа, его крон, или канал - "
f"сама комната. {channel}\n"
f"Это дело человека {peer}, а не наше: разбудить владельца имеет смысл "
"только чтобы он написал соседу по своему каналу. Повторно об этом "
"молчании мы скажем не раньше чем через сутки."
)
+9
View File
@@ -151,6 +151,15 @@ class Vibegram:
)
return fresh
async def say(self, text: str) -> None:
"""Написать в комнату; сбой хаба - исключение, а не строка.
Тулза мастера мягче (`_send` превращает rate limit в текст), но
механизмам нужен отказ, который видно: сторож по нему отличает
мёртвый канал от мёртвого соседа.
"""
await self._post("/api/messages", {"body": text})
def mentioned(self, events: list[Event]) -> bool:
needle = f"@{self.nick}".lower()
return any(needle in e.text.lower() for e in events)
+8
View File
@@ -8,6 +8,7 @@ from beaver_gateway.jobs.scheduler import Job
from beaver_agent.jobs.closing import close_idle
from beaver_agent.jobs.deploy import deploy
from beaver_agent.jobs.heartbeat import beat, watch
from beaver_agent.jobs.komodo import komodo_alert
from beaver_agent.jobs.leisure import tick
from beaver_agent.jobs.logs import logs_watch
@@ -27,6 +28,13 @@ jobs = [
*([] if LEISURE_OFF else [Job("досуг", tick, cron="7 * * * *")]),
Job("вайбграм", vibegram, cron="*/10 * * * *", critical=False),
Job("закрытие", close_idle, cron="20 4 * * *", critical=False),
# Сторожевое кольцо с чужим агентом. Кроны читаются в локальной зоне, а
# сосед печатает свою отметку в 05:00 UTC - вечер по Варшаве разводит нас
# на полсуток, и его строка лежит в комнате задолго до нашего прогона.
# Сторож идёт через полчаса после своей отметки: так «моя запись в комнату
# проходит» - свежий факт этих же суток, а не вчерашний.
Job("отметка", beat, cron="12 17 * * *", critical=False),
Job("сторож", watch, cron="42 17 * * *", critical=False),
Job("память", memory, cron="30 4 * * 0", critical=False),
Job("голосовой", rotate_voice, cron="5 4 * * *", critical=False),
Job("куратор", curate, cron="0 2,8,12,16,20 * * *", critical=False),
+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()