Compare commits

..
7 Commits
15 changed files with 2029 additions and 21 deletions

No files matched your search

+3
View File
@@ -114,6 +114,9 @@ VIBEGRAM_REPO=
# Токен GitHub с доступом к репе (приватная); пусто - публичная
VIBEGRAM_REPO_TOKEN=
VIBEGRAM_HB_TOKEN=
VIBEGRAM_HB_NICK=
### Досуг (ежечасный тик мастера, с S13) ######################################
# Час активного окна (12:00-03:59) и четыре часа тихого (04:00-11:59) будят
+116
View File
@@ -204,6 +204,122 @@ publishes no reply, so the only voice in it is the `say` tool.
Switches: `досуг: выкл` as the first line of the file for every day, and
`LEISURE=off` in `.env` to drop the job entirely.
## The watchdog ring (`beaver_agent/jobs/heartbeat.py`)
An agreement with a foreign agent on someone else's home server: both print
one line a day into a vibegram room of their own, and both watch the other's
silence. The two rates are deliberately different: the beat is daily because
that is the period we promise the neighbour, while the check runs every six
hours, because a watchdog stepping once a day against a 36 h threshold would
only notice silence some 60 h in - it would be measuring the wrong thing. If the neighbour goes quiet past the threshold, *their* human is the
one who needs to hear about it, so the alarm goes to the master thread as a
spawned triage job and the dispatcher decides whether to wake the owner.
The room is a **second, independent** `Vibegram` client (`VIBEGRAM_HB`), not
the one the dispatcher's `vibegram` tool and the triage job share. `/api/pending`
is a cursor on the hub's side: one client for both rooms would mean the triage
job eats the heartbeats, and the heartbeats drown in conversation. The model
gets no tool for this room - the watchdog is a mechanism.
The point of a watchdog is that it does not lie: an instrument that quietly
fails to watch is worse than no instrument, because it looks healthy. So there
are no defaults anywhere in it.
- `отметка` (17:12 local) prints `hb <nick> at=… boot=… every=… src=…`. The
period is not a constant: it is derived from that job's own registered cron
(`run.job.cron`). A schedule that does not reduce to "every N hours" gets
`every=unknown src=none` - lying with a number is worse than refusing.
- `сторож` (`:42` every six hours) asks for an explicit window (`pending(limit=25)`),
finds the newest `hb claude-kai …`, and compares its age against
`every * 1.5` where `every` comes **from the neighbour's own line**. The
clock it trusts is `seen` - when *we* saw the line - not the `at=` inside it,
which is the neighbour's statement about himself.
- Every run prints `ack claude-kai last=… seen=… res=… win=… armed=… thr=…`
into the room. Anything the run could not measure says so out loud there:
`every=unknown` in his line falls back to the last known period and marks the
threshold `thr=36h-fallback`; a period never read once is `thr=unknown` and
no alarm is taken.
- Never having seen a beat is not an alarm, it is `armed=no` - there is nothing
to watch yet. Seven days of `armed=no` in a row tells the human once, so an
unfinished ring does not quietly become permanent.
- If the window's highest event id went **backwards** since the last run, the
window is looking at the past: `win=stale`, and an alarm in that run is
forbidden. Same for a hub that did not answer at all.
- The alarm names all three possible causes - the neighbour's machine, his
cron, or the channel (the room itself) - and if our own write into the room
went through in that same run while neither `hb` nor `ack` came back from
him, it says to suspect the channel before the neighbour's death. It repeats
at most once a day, not once a run.
State between runs is `мета/бобер/.состояние/сторож.json`; a lost file means
the ring disarms itself and starts the seven-day count again.
Setup is two variables in `.env` and one manual step:
```bash
# join the watchdog room with the invite code the neighbour sends
python3 scripts/vibegram_join.py <join-code> --nick <nick>
# put the token into .env as VIBEGRAM_HB_TOKEN, then
docker compose restart gateway
```
`VIBEGRAM_HB_NICK` is optional - without it the nick is `VIBEGRAM_NICK`. With
no `VIBEGRAM_HB_TOKEN` both jobs do nothing and the log says once at startup
that the watchdog is not configured. The neighbour's nick is the `PEER`
constant in `beaver_agent/hands/heartbeat.py`.
## The job-run watchdog (`beaver_agent/jobs/runs.py`)
The scheduler has always written one row per run into `job_runs` - name,
status, the head of the traceback in `error`. Nobody ever read it. The channel
to the neighbour agent was dead for 38 hours straight, the scheduler recorded
that 231 times, and nothing reached a human: the outage surfaced ten days later,
by hand, out of curiosity. The record existed, the reader did not. This job is
the reader.
- `прогоны` (`:27` every hour) reads `job_runs` for the last 24 hours through
the same connection the scheduler itself writes with
(`run.conversations.db.session()` and the `JobRunRecord` model) - no second
truth about where the journal lives.
- An alarm is taken only off the **tail**: three or more `failed` in a row
counting back from a job's latest run, i.e. the job is *lying down* rather
than having blinked once. A single failure is a network hiccup, and waking
a human for it teaches them not to look.
- The alarm says the job's name, how many runs in a row died, when the series
started, and the **first line** of the error - never the traceback. Replayed
over the real outage it reads: «джоб `вайбграм` лежит 23 часа и дольше,
144 прогона подряд с 12.09 22:00 UTC: HubError: vibegram 530 bad_json». It repeats at most once a day per job, and
when the job recovers one short `отбой` line goes out and the counter resets.
- **An empty answer is a statement about the query, not about health.** Zero
rows in 24 hours (other than this job's own) means no job ran at all, and
that is its own alarm with its own text - the scheduler is what to look at,
not any one job. A failed query is no reading whatsoever: the run logs it and
stays silent, because a database that did not answer is not evidence of
health or of illness.
- Everything that goes out is masked (`mask`): the gateway's shared `redact`
plus two local rules for what it cannot catch by construction - a
`NAME=value` pair in the middle of a line (a traceback puts them there) and
the query string of any URL, which is dropped whole because the parameter
carrying the key can be named anything.
State between runs is `мета/бобер/.состояние/прогоны.json` (who was told and
when). A lost file means a lying job is reported again and an `отбой` for
something reported before the loss never comes - the evidence for it is gone.
Two blind spots, both deliberate:
- **The watchdog cannot report its own death.** Its own runs are excluded from
the count (`run.job.name`), because a job that is down will not file a report
about itself - an observer inside the object does not report the object's
death. If `прогоны` itself starts failing, nothing here will say so; that
needs an outside eye, like the watchdog ring above.
- **The window is 24 hours, so a job that runs less than three times a day can
never accumulate three consecutive failures inside it.** `отметка`,
`закрытие`, `голосовой` (daily) and `память` (weekly) are therefore
unwatched by this job in practice; `вайбграм`, `логи`, `куратор`, `досуг`,
`ротация` and `сторож` are inside it. Widening the counting window past the
query window is the fix, when it is worth it.
## Policy (`beaver_agent/policy.py`)
`bypassPermissions` everywhere; the boundary is `PreToolUse` rules declared
+6 -6
View File
@@ -134,14 +134,14 @@ def raycast(name: str, model: str, effort: str) -> RaycastAgent:
agents: list[BaseAgent] = [
dispatcher("beaver-dispatcher", "claude-opus-5", "high"),
deep("beaver-opus-high", "claude-opus-5", "high"),
deep("beaver-opus-medium", "claude-opus-5", "medium"),
deep("beaver-opus-xhigh", "claude-opus-5", "xhigh"),
dispatcher("beaver-dispatcher", "claude-opus-5-5", "high"),
deep("beaver-opus-high", "claude-opus-5-5", "high"),
deep("beaver-opus-medium", "claude-opus-5-5", "medium"),
deep("beaver-opus-xhigh", "claude-opus-5-5", "xhigh"),
deep("beaver-fable-high", "claude-fable-5-1", "high"),
deep("beaver-fable-medium", "claude-fable-5-1", "medium"),
distiller("beaver-distiller", "claude-opus-5", "medium"),
curator("beaver-curator", "claude-opus-5", "high"),
distiller("beaver-distiller", "claude-opus-5-5", "medium"),
curator("beaver-curator", "claude-opus-5-5", "high"),
triage("beaver-triage", "claude-sonnet-5", "low"),
voice(VOICE, "claude-haiku-4-5-20251001"),
raycast("beaver-gemini-pro-high", "google-gemini-3.1-pro", "high"),
+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}, а не наше: разбудить владельца имеет смысл "
"только чтобы он написал соседу по своему каналу. Повторно об этом "
"молчании мы скажем не раньше чем через сутки."
)
+395
View File
@@ -0,0 +1,395 @@
"""Сторож собственных прогонов: журнал `job_runs` наконец кто-то читает.
Планировщик gateway пишет строку на каждый прогон джоба (`job_runs`: имя,
статус, хвост трейсбека в `error`). Запись была всегда, читателя не было
ни одного: канал к соседнему агенту лежал 38 часов подряд, планировщик
честно записал это 231 раз, и до человека не доехало ничего. Этот модуль -
вторая половина прибора: показание из таблицы превращается в тревогу.
Что здесь считается показанием:
- тревога берётся только с ХВОСТА: три и более `failed` подряд, считая от
последнего прогона джоба. Одиночный упавший прогон - сетевой чих, а не
болезнь, и будить на него человека значит выучить его не смотреть;
- пустая выдача - это показание о запросе, а не о здоровье джобов. Ноль
строк за сутки (кроме своих) означает, что планировщик вообще не
работал, и это отдельная тревога с отдельным текстом, а не тишина;
- упавший запрос к БД - не показание вовсе: решает джоб, здесь такого
входа просто нет.
Свои собственные прогоны из счёта выкинуты (`me`): джоб, который лежит,
о себе не доложит - наблюдатель внутри объекта не сообщает о смерти
объекта. Это дыра прибора, и она названа в README, а не заклеена.
Текст тревоги проходит `mask`: из `error` берётся первая строка, режется
по длине и чистится - общим `redact` gateway плюс два своих правила на то,
что `redact` по конструкции не ловит внутри строки (`ИМЯ=значение` не с
начала строки и хвост запроса у URL). Тревога уезжает в чат к диспетчеру,
логи гейтвея видны в чатах - секрету тут негде быть.
"""
from __future__ import annotations
import itertools
import json
import re
from dataclasses import dataclass, field
from datetime import UTC, datetime, timedelta
from pathlib import Path # noqa: TC003 - в аннотации dataclass
from typing import TYPE_CHECKING
from beaver_gateway.security.redact import MASK, is_secret_name, redact
if TYPE_CHECKING:
from collections.abc import Sequence
__all__ = [
"ERROR_CHARS",
"FAILED",
"FORGET",
"MAX_LINES",
"REPEAT",
"STREAK",
"WINDOW",
"Clear",
"Report",
"Run",
"Runs",
"Streak",
"alarm_text",
"clear_text",
"empty_text",
"first_line",
"look",
"mask",
"streak",
]
WINDOW = timedelta(hours=24)
"""Окно журнала: сутки. Джоб реже трёх раз в сутки за него не наберёт серии
из трёх падений - это слепая зона, названная в README."""
STREAK = 3
"""Сколько `failed` подряд считаем «джоб лежит»."""
REPEAT = timedelta(hours=24)
"""Чаще этого об одном и том же джобе не говорим."""
FORGET = timedelta(days=7)
"""Джоб, о котором тревожились, исчез из окна насовсем - забываем его молча:
починки мы не видели, врать «отбой» не о чем."""
ERROR_CHARS = 160
"""Сколько букв первой строки ошибки уезжает человеку."""
MAX_LINES = 10
"""Сколько джобов перечисляем в одной тревоге: сразу всё не читают."""
FAILED = "failed"
STATE_VERSION = 1
_PAIR = re.compile(r"(?=([A-Za-z][\w.-]{2,})\s*([:=])\s*([^\s,;)\]}\"']{6,}))")
"""`ИМЯ=значение` где угодно в строке: общий `redact` ловит такие пары только
с начала строки, а трейсбек кладёт их в середину. Просмотр нулевой длины -
чтобы `RuntimeError: MSOS_MCP=…` не съедало секрет первой же парой: обычный
`sub` откусил бы `RuntimeError:` вместе со значением и до вложенной пары
не дошёл."""
_QUERY = re.compile(r"(https?://[^\s?#]+)\?[^\s#]*")
"""Хвост запроса у URL сносим целиком: имя параметра с ключом может быть любым."""
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 _plural(count: int, forms: tuple[str, str, str]) -> str:
if count % 10 == 1 and count % 100 != 11:
return forms[0]
if count % 10 in (2, 3, 4) and count % 100 not in (12, 13, 14):
return forms[1]
return forms[2]
def _age(delta: timedelta) -> str:
"""Возраст человеку: «4 часа», «45 минут». Секунды тут не нужны никому."""
minutes = max(int(delta.total_seconds()) // 60, 0)
if minutes < 60:
return f"{minutes} {_plural(minutes, ('минуту', 'минуты', 'минут'))}"
hours = minutes // 60
return f"{hours} {_plural(hours, ('час', 'часа', 'часов'))}"
def _mask_pairs(text: str) -> str:
out: list[str] = []
pos = 0
for match in _PAIR.finditer(text):
if match.start(3) < pos or not is_secret_name(match[1]):
continue
out.append(text[pos : match.start(3)])
out.append(MASK)
pos = match.end(3)
out.append(text[pos:])
return "".join(out)
def mask(text: str) -> str:
"""Убрать из строки всё, что похоже на секрет, прежде чем её показывать."""
return _QUERY.sub(r"\1?" + MASK, _mask_pairs(redact(text)))
def first_line(error: str | None) -> str:
"""Первая строка ошибки - чистая и короткая. Трейсбек человеку не нужен."""
for line in mask(error or "").splitlines():
head = line.strip()
if not head:
continue
if len(head) > ERROR_CHARS:
head = head[: ERROR_CHARS - 1].rstrip() + "…"
return head
return "без текста ошибки"
@dataclass(frozen=True, slots=True)
class Run:
"""Одна строка журнала: ровно то, на что мы смотрим, без ORM."""
id: int
job: str
status: str
started_at: datetime
error: str | None = None
@property
def failed(self) -> bool:
return self.status == FAILED
@dataclass(frozen=True, slots=True)
class Streak:
"""Хвост подряд упавших прогонов одного джоба."""
job: str
count: int
since: datetime
"""Начало серии - первое падение из тех, что видно в окне."""
age: timedelta
error: str
capped: bool
"""Серия упёрлась в край окна: падений было НЕ МЕНЬШЕ `count`, а сколько
на самом деле - за сутками не видно, и врать точным числом нельзя."""
@dataclass(frozen=True, slots=True)
class Clear:
"""Джоб, о котором мы тревожились, больше не лежит."""
job: str
status: str
"""Статус последнего прогона."""
count: int
"""Сколько подряд упавших осталось в хвосте: 0 - чисто."""
@dataclass(slots=True)
class Runs:
"""Состояние между прогонами: кому и когда уже сказали."""
path: Path
alerted: dict[str, datetime] = field(default_factory=dict)
"""Джоб → когда о нём ушла тревога. Он же признак «сейчас в тревоге»."""
empty_alerted: datetime | None = None
@classmethod
def load(cls, path: Path) -> Runs:
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)
alerted = {}
for job, when in dict(raw.get("alerted") or {}).items():
parsed = _parse_iso(str(when))
if parsed is not None:
alerted[str(job)] = parsed
return cls(
path=path,
alerted=alerted,
empty_alerted=_parse_iso(raw.get("empty_alerted")),
)
def save(self) -> None:
body = {
"version": STATE_VERSION,
"alerted": {job: _iso(when) for job, when in sorted(self.alerted.items())},
"empty_alerted": _iso(self.empty_alerted),
}
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:
"""Что решил один прогон сторожа журнала."""
alarms: tuple[Streak, ...]
clears: tuple[Clear, ...]
empty: bool
"""Прогонов за сутки не было вовсе - тревога о планировщике, не о джобе."""
seen: int
"""Сколько чужих прогонов было в окне."""
mine: int
"""Сколько своих - их в счёт не берём, но в тексте называем."""
note: str
def streak(runs: Sequence[Run], *, now: datetime) -> Streak | None:
"""Хвост падений одного джоба, считая от последнего прогона.
`runs` - прогоны одного джоба, новейший первым. Серия, упёршаяся в край
окна, помечается `capped`: её настоящую длину мы не знаем.
"""
failures = list(itertools.takewhile(lambda run: run.failed, runs))
if not failures:
return None
oldest = failures[-1]
return Streak(
job=failures[0].job,
count=len(failures),
since=oldest.started_at,
age=now - oldest.started_at,
error=first_line(failures[0].error),
capped=len(failures) == len(runs),
)
def look(state: Runs, runs: Sequence[Run], *, now: datetime, me: str) -> Report:
"""Разобрать окно журнала и решить, о чём говорить человеку.
Состояние здесь только чистится от забытых джобов; отметки «сказали» и
«сказали про пустоту» ставит джоб - и только когда тревога действительно
ушла: несостоявшаяся доставка не должна заглушать следующий прогон.
"""
mine = sum(1 for run in runs if run.job == me)
others = sorted(
(run for run in runs if run.job != me), key=lambda run: run.id, reverse=True
)
if not others:
quiet = state.empty_alerted is not None and now - state.empty_alerted < REPEAT
return Report(
alarms=(),
clears=(),
empty=not quiet,
seen=0,
mine=mine,
note=(
f"чужих прогонов за сутки нет вовсе (своих {mine})"
+ ("; о пустоте уже говорили меньше суток назад" if quiet else "")
),
)
by_job: dict[str, list[Run]] = {}
for run in others:
by_job.setdefault(run.job, []).append(run)
alarms: list[Streak] = []
clears: list[Clear] = []
held: list[str] = []
for job, rows in by_job.items():
tail = streak(rows, now=now)
if tail is not None and tail.count >= STREAK:
told = state.alerted.get(job)
if told is None or now - told >= REPEAT:
alarms.append(tail)
else:
held.append(job)
elif job in state.alerted:
clears.append(
Clear(
job=job,
status=rows[0].status,
count=0 if tail is None else tail.count,
)
)
forgotten = [
job
for job, told in state.alerted.items()
if job not in by_job and now - told >= FORGET
]
for job in forgotten:
del state.alerted[job]
note = f"прогонов за сутки {len(others)} по {len(by_job)} джобам (своих {mine})"
if alarms:
note += "; лежат: " + ", ".join(sorted(tail.job for tail in alarms))
if held:
note += "; уже говорили меньше суток назад: " + ", ".join(sorted(held))
if clears:
note += "; починились: " + ", ".join(sorted(clear.job for clear in clears))
if forgotten:
note += "; забыты (пропали из окна): " + ", ".join(sorted(forgotten))
return Report(
alarms=tuple(sorted(alarms, key=lambda tail: tail.count, reverse=True)),
clears=tuple(sorted(clears, key=lambda clear: clear.job)),
empty=False,
seen=len(others),
mine=mine,
note=note,
)
def streak_line(tail: Streak) -> str:
"""«джоб `вайбграм` лежит 4 часа, 24 прогона подряд с 12.09 18:00 UTC: …»."""
runs = f"{tail.count} {_plural(tail.count, ('прогон', 'прогона', 'прогонов'))}"
return (
f"джоб `{tail.job}` лежит {_age(tail.age)}"
f"{' и дольше' if tail.capped else ''}, {runs} подряд с "
f"{tail.since.astimezone(UTC).strftime('%d.%m %H:%M UTC')}"
+ (" (это всё окно суток, серия началась раньше)" if tail.capped else "")
+ f": {tail.error}"
)
def alarm_text(report: Report) -> str:
"""Тревога человеку: что лежит, сколько и с какой ошибкой."""
lines = [f"- {streak_line(tail)}" for tail in report.alarms[:MAX_LINES]]
rest = len(report.alarms) - len(lines)
if rest > 0:
lines.append(f"- и ещё {rest} {_plural(rest, ('джоб', 'джоба', 'джобов'))}")
return "\n".join(lines)
def clear_line(clear: Clear) -> str:
if clear.count == 0:
return f"отбой: джоб `{clear.job}` снова проходит ({clear.status})"
return (
f"отбой: джоб `{clear.job}` больше не лежит подряд "
f"(упавших в хвосте {clear.count}, порог {STREAK})"
)
def clear_text(report: Report) -> str:
return "\n".join(f"- {clear_line(clear)}" for clear in report.clears)
def empty_text(report: Report) -> str:
"""Отдельная тревога: за сутки не отработал вообще никто."""
return (
f"За последние сутки в журнале прогонов (`job_runs`) нет ни одной записи, "
f"кроме моих собственных (их {report.mine}): не отработал ни один джоб. "
"Это показание о планировщике, а не о каком-то одном джобе. Смотреть "
"стоит три вещи: поднялся ли планировщик вообще (в логе gateway "
"`scheduler: no postgres driver - cron and webhooks are off`), живы ли "
"кроны в pgqueuer, и не заведена ли база заново - на пустой базе эта же "
"тревога выглядит так же."
)
+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)
+18
View File
@@ -8,11 +8,13 @@ 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
from beaver_agent.jobs.memory import curate, memory
from beaver_agent.jobs.rotation import rotate
from beaver_agent.jobs.runs import watch_runs
from beaver_agent.jobs.t3code import t3code_event
from beaver_agent.jobs.vibegram import vibegram
from beaver_agent.jobs.voice import rotate_voice
@@ -27,6 +29,22 @@ 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 - вечер по Варшаве разводит нас
# на полсуток, и его строка лежит в комнате задолго до нашего прогона.
# Частоты тут две, и они разные: отметка суточная (её период мы обещаем
# соседу), а проверка - вчетверо чаще. Сторож с шагом в сутки при пороге
# 36 ч обнаруживал бы молчание только через ~60 ч, то есть мерил бы не то,
# ради чего заведён; чтение 25 событий стоит копейки.
Job("отметка", beat, cron="12 17 * * *", critical=False),
Job("сторож", watch, cron="42 */6 * * *", critical=False),
# Сторож собственного журнала прогонов. Час - это компромисс: окно счёта
# сутки, и джоб, упавший три раза подряд, лежит уже заметно дольше часа,
# так что чаще смысла нет, а реже - тревога стареет на свой же период.
# ":27" выбрана пустой: на ":00" ротация и куратор, на ":40" логи, на
# ":42" сторож соседа, а собственный прогон этого джоба не должен ждать
# очереди за чужим спавном.
Job("прогоны", watch_runs, cron="27 * * * *", 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()
+146
View File
@@ -0,0 +1,146 @@
"""Сторож собственных прогонов: раз в час читает `job_runs` и будит человека.
Подключение к базе - то же самое, которым пишет сам планировщик
(`run.conversations.db.session()` и модель `JobRunRecord`): своей строки
подключения у джоба нет, и второй правды о том, где журнал, тоже.
Устроен как сторож соседа (`beaver_agent/jobs/heartbeat.py`): состояние
файлом в `мета/бобер/.состояние/`, тревога - спавном джоба к триажу, и
запись «сказали» ставится только после того, как тревога ушла.
"""
from __future__ import annotations
import logging
from datetime import UTC, datetime
from typing import TYPE_CHECKING
from beaver_gateway.storage.models import JobRunRecord
from sqlmodel import col, select
from beaver_agent.hands.runs import (
STREAK,
WINDOW,
Report,
Run,
Runs,
alarm_text,
clear_text,
empty_text,
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 = "прогоны"
LIMIT = 5000
"""Потолок строк за окно. Сутки нормальной работы - это сотни строк; упереться
в потолок можно только при шторме, и тогда серии считаются по новейшим, а
недостающий хвост честно виден как `capped` в тексте тревоги."""
BRIEF = (
"Сторож собственных прогонов (таблица `job_runs`, окно сутки) нашёл "
"лежащие джобы: у каждого {streak} и больше падений подряд, считая от "
"последнего прогона.\n"
"{body}\n"
'Будить владельца - inject(conversation="master", urgency="wake", '
"text=до 3 строк: какой джоб лежит, сколько прогонов подряд и что в "
"ошибке). Это авария у НАС, а не у соседа: джоб пишется в журнал каждый "
"прогон, и раз он падает подряд, сам он не починится. Подробности - "
"в логах gateway. Если inject вернул ошибку - это сбой доставки, а не "
"«не срочно»: повтори один раз с тем же текстом."
)
CLEAR_BRIEF = (
"Сторож собственных прогонов: то, о чём он тревожился, починилось.\n"
"{body}\n"
'Владельца НЕ будить: inject(conversation="master", urgency="normal", '
"text=одна строка «отбой»). Человек знает про тревогу, ему нужно знать, "
"что она кончилась."
)
EMPTY_BRIEF = (
"Сторож собственных прогонов не нашёл в журнале НИЧЕГО.\n"
"{body}\n"
'Будить владельца - inject(conversation="master", urgency="wake", '
"text=до 3 строк: что за сутки не отработал ни один джоб и куда смотреть). "
"Молчащий планировщик - это молчание всех джобов разом, включая тех, что "
"носят человеку почту."
)
async def recent(run: JobRun, *, since: datetime) -> list[Run]:
"""Прогоны всех джобов за окно, новейшие первыми.
`started_at` в базе лежит наивным UTC (так его кладёт планировщик), и
сравнение в `where` тоже наивное - как это делает gateway в своих
запросах по времени.
"""
stmt = (
select(JobRunRecord)
.where(
col(JobRunRecord.started_at) >= since.astimezone(UTC).replace(tzinfo=None)
)
.order_by(col(JobRunRecord.id).desc())
.limit(LIMIT)
)
async with run.conversations.db.session() as session:
rows = (await session.exec(stmt)).all()
return [
Run(
id=row.id or 0,
job=row.job,
status=row.status,
started_at=(
row.started_at
if row.started_at.tzinfo
else row.started_at.replace(tzinfo=UTC)
),
error=row.error,
)
for row in rows
]
async def watch_runs(run: JobRun) -> None:
"""Прогон сторожа: журнал → решение → тревога, «отбой» или молчание."""
now = datetime.now(UTC)
try:
rows = await recent(run, since=now - WINDOW)
except Exception: # noqa: BLE001 - отказ запроса это отсутствие показания
# Журнал не прочитан - сказать о здоровье джобов нечего, и молчание
# базы выдавать за здоровье нельзя ровно так же, как за болезнь.
_log.exception("прогоны: журнал не прочитан, прогон без показания")
return
state = Runs.load(STATE_FILE)
report = look(state, rows, now=now, me=run.job.name)
_log.info("прогоны: %s", report.note)
await _tell(run, report, state, now=now)
state.save()
async def _tell(run: JobRun, report: Report, state: Runs, *, now: datetime) -> None:
if report.empty:
await _spawn(run, EMPTY_BRIEF.format(body=empty_text(report)))
state.empty_alerted = now
if report.alarms:
await _spawn(run, BRIEF.format(body=alarm_text(report), streak=STREAK))
for tail in report.alarms:
state.alerted[tail.job] = now
if report.clears:
await _spawn(run, CLEAR_BRIEF.format(body=clear_text(report)))
for clear in report.clears:
state.alerted.pop(clear.job, None)
async def _spawn(run: JobRun, text: str) -> None:
await run.spawn_job(agent="beaver-triage", title=TITLE, text=text)
+87
View File
@@ -0,0 +1,87 @@
#!/usr/bin/env python3
"""Войти в комнату вайбграма по коду приглашения и получить токен агента.
Обычный путь - CLI `vibegram join <code> --nick <ник> --no-hooks` из клона
репы комнаты. Сторожевой комнате клон не нужен (в ней только строки `hb` и
`ack`), поэтому здесь тот же вызов хаба напрямую: `POST /api/rooms/join`
с `joinCode` и `nick`. Ответ печатается целиком - токен из него едет в
`.env` как `VIBEGRAM_HB_TOKEN`, после чего gateway надо перезапустить.
python3 scripts/vibegram_join.py <join-code> --nick <ник>
python3 scripts/vibegram_join.py <join-code> --hub https://vibegram.example
Хаб по умолчанию берётся из `VIBEGRAM_HUB` в окружении или в соседнем `.env`.
Токен печатается в терминал - не вставляй вывод в чаты и в задачи.
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import urllib.error
import urllib.request
from pathlib import Path
ENV = Path(__file__).resolve().parent.parent / ".env"
USER_AGENT = "beaver-agent/0.1 (+vibegram)"
def hub_from_env() -> str | None:
if os.environ.get("VIBEGRAM_HUB"):
return os.environ["VIBEGRAM_HUB"]
if not ENV.exists():
return None
for line in ENV.read_text(encoding="utf-8").splitlines():
key, _, value = line.partition("=")
if key.strip() == "VIBEGRAM_HUB" and value.strip():
return value.strip()
return None
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("code", help="код приглашения из комнаты")
parser.add_argument("--nick", help="ник агента в этой комнате")
parser.add_argument("--hub", default=hub_from_env(), help="адрес хаба вайбграма")
args = parser.parse_args()
if not args.hub:
print("не знаю адрес хаба: задай --hub или VIBEGRAM_HUB", file=sys.stderr)
return 2
body: dict[str, str] = {"joinCode": args.code}
if args.nick:
body["nick"] = args.nick
request = urllib.request.Request( # noqa: S310 - адрес хаба задаёт оператор
f"{args.hub.rstrip('/')}/api/rooms/join",
data=json.dumps(body).encode(),
# Перед хабом Cloudflare: безымянный `Python-urllib` он отбивает 1010.
# Тот же user-agent, что у клиента в `beaver_agent/hands/vibegram.py`.
headers={"content-type": "application/json", "user-agent": USER_AGENT},
method="POST",
)
try:
with urllib.request.urlopen(request, timeout=20) as response: # noqa: S310
data = json.loads(response.read())
except urllib.error.HTTPError as exc:
print(f"хаб отказал: {exc.code} {exc.read().decode()[:400]}", file=sys.stderr)
return 1
except OSError as exc:
print(f"хаб не отвечает: {exc}", file=sys.stderr)
return 1
print(json.dumps(data, ensure_ascii=False, indent=2))
token = next(
(data[k] for k in ("token", "agentToken", "apiToken") if data.get(k)), None
)
if token:
print(f"\nVIBEGRAM_HB_TOKEN={token}\n\nдальше: строку в .env, затем рестарт")
else:
print("\nтокена в ответе не видно - смотри поля выше", file=sys.stderr)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+322
View File
@@ -0,0 +1,322 @@
from datetime import UTC, datetime, timedelta
from types import SimpleNamespace
from typing import Any, cast
from zoneinfo import ZoneInfo
from beaver_agent.hands.heartbeat import (
PEER,
Watch,
alarm_text,
beat_line,
every_from_cron,
look,
parse_beat,
parse_span,
span,
)
from beaver_agent.hands.vibegram import Event
WARSAW = ZoneInfo("Europe/Warsaw")
NOW = datetime(2026, 9, 22, 15, 42, tzinfo=UTC)
def hb(at, *, every="24h", nick=PEER, eid=100):
return Event(
id=eid,
nick=nick,
kind="message",
text=(
f"{nick}: hb {nick} at={at.strftime('%Y-%m-%dT%H:%M:%SZ')} "
f"boot=2026-09-01T03:00:00Z every={every} src=cron+uptime-s"
),
at="09-22 07:00",
)
def state(tmp_path, **kw):
return Watch(path=tmp_path / "сторож.json", **kw)
def test_parse_beat_reads_fields_and_unknown_every():
beat = parse_beat(
"claude-kai: hb claude-kai at=2026-09-22T05:00:00Z "
"boot=2026-09-01T03:00:00Z every=24h src=cron+uptime-s"
)
assert beat is not None
assert beat.nick == "claude-kai"
assert beat.at == datetime(2026, 9, 22, 5, 0, tzinfo=UTC)
assert beat.boot == datetime(2026, 9, 1, 3, 0, tzinfo=UTC)
assert beat.every == timedelta(hours=24)
unknown = parse_beat(
"claude-kai: hb claude-kai at=2026-09-22T05:00:00Z "
"boot=? every=unknown src=none"
)
assert unknown is not None
assert unknown.every is None and unknown.every_raw == "unknown"
assert unknown.boot is None
assert parse_beat("claude-kai: просто сообщение") is None
assert parse_beat("claude-kai: ack beaver last=x", "ack") is not None
def test_span_round_trip():
assert span(timedelta(hours=36)) == "36h"
assert span(timedelta(minutes=90)) == "90m"
assert span(None) == "unknown"
assert parse_span("36h") == timedelta(hours=36)
assert parse_span("unknown") is None
assert parse_span("") is None
def test_every_from_cron_only_when_schedule_is_every_n_hours():
assert every_from_cron("12 17 * * *", WARSAW, now=NOW) == timedelta(hours=24)
assert every_from_cron("0 */6 * * *", WARSAW, now=NOW) == timedelta(hours=6)
# Перевод стрелок не делает суточный крон 23-часовым: считаем по стенным часам
march = datetime(2026, 3, 28, 12, tzinfo=UTC)
assert every_from_cron("12 17 * * *", WARSAW, now=march) == timedelta(hours=24)
# Не сводится к «раз в N часов» - и не сводим
assert every_from_cron("0 2,8,12,16,20 * * *", WARSAW, now=NOW) is None
assert every_from_cron("*/10 * * * *", WARSAW, now=NOW) is None
# Недельный крон тоже сводится к N часам - это честные 168h, а не unknown
assert every_from_cron("30 4 * * 0", WARSAW, now=NOW) == timedelta(hours=168)
assert every_from_cron(None, WARSAW, now=NOW) is None
assert every_from_cron("не крон", WARSAW, now=NOW) is None
def test_beat_line_says_unknown_instead_of_guessing():
line = beat_line(
nick="beaver",
at=datetime(2026, 9, 22, 15, 12, tzinfo=UTC),
boot=datetime(2026, 9, 20, 1, 2, 3, tzinfo=UTC),
every=timedelta(hours=24),
)
assert line == (
"hb beaver at=2026-09-22T15:12:00Z boot=2026-09-20T01:02:03Z every=24h src=cron"
)
blind = beat_line(nick="beaver", at=NOW, boot=NOW, every=None)
assert "every=unknown src=none" in blind
def test_fresh_beat_arms_and_stays_quiet(tmp_path):
w = state(tmp_path)
report = look(w, [hb(NOW - timedelta(hours=10))], now=NOW, res=timedelta(hours=24))
assert report.armed and report.alarm is None and not report.stale
assert report.threshold == timedelta(hours=36)
assert "armed=yes" in report.ack and "win=25" in report.ack
assert "res=24h" in report.ack and "thr=36h" in report.ack
assert w.seen == NOW and w.top == 100
def test_threshold_is_every_times_one_and_a_half(tmp_path):
# 30 часов при every=24h - ещё не тревога: одна потерянная строка не в счёт
quiet = state(tmp_path, seen=NOW - timedelta(hours=30), every=timedelta(hours=24))
assert look(quiet, [], now=NOW, res=timedelta(hours=24)).alarm is None
# 37 часов - уже за порогом 36
loud = state(tmp_path, seen=NOW - timedelta(hours=37), every=timedelta(hours=24))
report = look(loud, [], now=NOW, res=timedelta(hours=24))
assert report.alarm == "silence"
# Порог берётся из строки соседа, а не из нашей константы
slow = state(tmp_path, seen=NOW - timedelta(hours=37), every=timedelta(hours=48))
assert look(slow, [], now=NOW, res=timedelta(hours=24)).alarm is None
def test_last_beat_wins_over_gaps(tmp_path):
w = state(tmp_path, seen=NOW - timedelta(hours=40), every=timedelta(hours=24))
report = look(w, [hb(NOW - timedelta(hours=1), eid=120)], now=NOW, res=None)
assert report.alarm is None
assert report.age is not None and report.age < timedelta(hours=2)
assert "res=unknown" in report.ack
def test_unknown_every_falls_back_to_last_known_and_says_so(tmp_path):
w = state(tmp_path, every=timedelta(hours=24))
report = look(w, [hb(NOW, every="unknown", eid=130)], now=NOW, res=None)
assert report.threshold == timedelta(hours=36)
assert "thr=36h-fallback" in report.ack
assert w.every == timedelta(hours=24)
def test_no_period_ever_means_no_measurement_and_no_alarm(tmp_path):
w = state(tmp_path, seen=NOW - timedelta(days=9))
report = look(w, [], now=NOW, res=timedelta(hours=24))
assert report.alarm is None and report.threshold is None
assert "thr=unknown" in report.ack
assert "мерить нечем" in report.note
def test_never_seen_is_armed_no_not_alarm(tmp_path):
w = state(tmp_path)
report = look(w, [], now=NOW, res=timedelta(hours=24))
assert not report.armed and report.alarm is None
assert "armed=no" in report.ack and "last=never seen=never" in report.ack
assert w.unarmed_since == NOW
def test_seven_days_unarmed_tells_the_human_once(tmp_path):
w = state(tmp_path, unarmed_since=NOW - timedelta(days=7))
assert look(w, [], now=NOW, res=timedelta(hours=24)).alarm == "unarmed"
w.unarmed_told = True
assert look(w, [], now=NOW, res=timedelta(hours=24)).alarm is None
# Пришла первая отметка - кольцо вооружилось, счётчик забыт
look(w, [hb(NOW, eid=140)], now=NOW, res=timedelta(hours=24))
assert w.unarmed_since is None
def test_stale_window_forbids_alarm(tmp_path):
w = state(
tmp_path, seen=NOW - timedelta(hours=40), every=timedelta(hours=24), top=500
)
report = look(w, [hb(NOW - timedelta(hours=40), eid=7)], now=NOW, res=None)
assert report.stale and report.alarm is None
assert "win=stale" in report.ack
assert w.top == 500 # съехавшее окно не двигает отметку максимума
# Окно снова вперёд - тревога берётся
fresh = look(w, [hb(NOW - timedelta(hours=40), eid=501)], now=NOW, res=None)
assert not fresh.stale
def test_alarm_not_repeated_within_a_day(tmp_path):
w = state(
tmp_path,
seen=NOW - timedelta(hours=40),
every=timedelta(hours=24),
alerted=NOW - timedelta(hours=5),
)
assert look(w, [], now=NOW, res=None).alarm is None
w.alerted = NOW - timedelta(hours=25)
assert look(w, [], now=NOW, res=None).alarm == "silence"
def test_alarm_text_names_three_causes_and_suspects_the_channel(tmp_path):
w = state(tmp_path, seen=NOW - timedelta(hours=40), every=timedelta(hours=24))
report = look(w, [], now=NOW, res=timedelta(hours=24))
silent = alarm_text(report, mine_sent=True)
assert "машина соседа" in silent and "крон" in silent and "канал" in silent
assert "канал подозревать надо раньше" in silent
# Сосед хоть чем-то отметился - канал скорее жив
heard = look(
state(tmp_path, seen=NOW - timedelta(hours=40), every=timedelta(hours=24)),
[
Event(
id=9,
nick=PEER,
kind="message",
text=f"{PEER}: ack beaver last=x",
at="09-22 07:00",
)
],
now=NOW,
res=None,
)
assert heard.heard
assert "канал скорее жив" in alarm_text(heard, mine_sent=True)
def test_state_survives_a_round_trip(tmp_path):
w = state(
tmp_path,
at=NOW,
seen=NOW,
every=timedelta(hours=24),
every_unknown=True,
top=42,
alerted=NOW,
unarmed_since=None,
unarmed_told=True,
)
w.save()
back = Watch.load(w.path)
assert back.at == NOW and back.seen == NOW and back.top == 42
assert back.every == timedelta(hours=24) and back.every_unknown
assert back.alerted == NOW and back.unarmed_told
assert Watch.load(tmp_path / "нет.json").seen is None
class FakeRoom:
def __init__(self, events, *, nick="beaver", send_fails=False):
self.events = events
self.nick = nick
self.send_fails = send_fails
self.said = []
self.limit = None
async def pending(self, limit=50):
self.limit = limit
return self.events
async def say(self, text):
if self.send_fails:
msg = "хаб молчит"
raise RuntimeError(msg)
self.said.append(text)
class FakeRun:
def __init__(self, cron="42 */6 * * *"):
self.job = SimpleNamespace(cron=cron, name="сторож")
self.scheduler = SimpleNamespace(tz=WARSAW)
self.spawned = []
async def spawn_job(self, *, agent, text, title=None):
self.spawned.append((agent, title, text))
return None
async def test_watch_job_prints_ack_and_spawns_alarm(tmp_path, monkeypatch):
from beaver_agent.jobs import heartbeat as job
monkeypatch.setattr(job, "STATE_FILE", tmp_path / "сторож.json")
Watch(
path=tmp_path / "сторож.json",
seen=datetime.now(UTC) - timedelta(hours=40),
every=timedelta(hours=24),
).save()
room = FakeRoom([])
monkeypatch.setattr(job, "VIBEGRAM_HB", room)
run = FakeRun()
await job.watch(cast("Any", run))
assert room.limit == 25 # окно запрошено явным размером
assert room.said and room.said[0].startswith(f"ack {PEER} ")
assert "res=6h" in room.said[0]
assert len(run.spawned) == 1
agent, title, text = run.spawned[0]
assert agent == "beaver-triage" and title == "сторож"
assert "канал подозревать надо раньше" in text
# Тревога записана в состояние - следующий прогон в те же сутки молчит
assert Watch.load(tmp_path / "сторож.json").alerted is not None
await job.watch(cast("Any", run))
assert len(run.spawned) == 1
async def test_beat_job_sends_period_from_its_own_cron(monkeypatch):
from beaver_agent.jobs import heartbeat as job
room = FakeRoom([], nick="beaver")
monkeypatch.setattr(job, "VIBEGRAM_HB", room)
await job.beat(cast("Any", FakeRun(cron="12 17 * * *")))
assert room.said[0].startswith("hb beaver at=")
assert "every=24h src=cron" in room.said[0]
odd = FakeRoom([], nick="beaver")
monkeypatch.setattr(job, "VIBEGRAM_HB", odd)
await job.beat(cast("Any", FakeRun(cron="0 2,8,12,16,20 * * *")))
assert "every=unknown src=none" in odd.said[0]
async def test_watch_job_keeps_quiet_when_the_room_is_unreadable(tmp_path, monkeypatch):
from beaver_agent.jobs import heartbeat as job
class Dead(FakeRoom):
async def pending(self, limit=50):
msg = "хаб не отвечает"
raise RuntimeError(msg)
monkeypatch.setattr(job, "STATE_FILE", tmp_path / "сторож.json")
monkeypatch.setattr(job, "VIBEGRAM_HB", Dead([]))
run = FakeRun()
await job.watch(cast("Any", run))
assert not run.spawned
assert not (tmp_path / "сторож.json").exists()
+11 -6
View File
@@ -1,4 +1,6 @@
import shutil
import tempfile
from collections.abc import Iterator
from pathlib import Path
import pytest
@@ -15,13 +17,16 @@ from beaver_agent.policy import (
@pytest.fixture
def vault() -> Path:
def vault() -> Iterator[Path]:
root = Path(tempfile.mkdtemp(prefix="beaver-vault-"))
for d in ("мета/бобер", "💬 чаты", "📅 дни", "👤 люди"):
(root / d).mkdir(parents=True)
(root / "💬 чаты/старый.md").write_text("x")
(root / "📅 дни/2026-08-29.md").write_text("x")
return root
try:
for d in ("мета/бобер", "💬 чаты", "📅 дни", "👤 люди"):
(root / d).mkdir(parents=True)
(root / "💬 чаты/старый.md").write_text("x")
(root / "📅 дни/2026-08-29.md").write_text("x")
yield root
finally:
shutil.rmtree(root, ignore_errors=True)
@pytest.fixture
+366
View File
@@ -0,0 +1,366 @@
from datetime import UTC, datetime, timedelta
from types import SimpleNamespace
from typing import Any, cast
import pytest
from beaver_agent.hands.runs import (
STREAK,
Run,
Runs,
alarm_text,
clear_text,
empty_text,
first_line,
look,
mask,
)
NOW = datetime(2026, 9, 22, 15, 42, tzinfo=UTC)
BOOM = (
"HubError: vibegram 530 bad_json: <!doctype html>\n"
"Traceback (most recent call last):\n"
' File "/app/src/beaver_gateway/jobs/scheduler.py", line 170, in _dispatch\n'
"HubError: vibegram 530 bad_json"
)
def run(rid, job, status, *, ago, error=None):
return Run(
id=rid,
job=job,
status=status,
started_at=NOW - timedelta(minutes=ago),
error=error if error is not None or status != "failed" else BOOM,
)
def series(job, statuses, *, step=10, start=1000):
"""Прогоны джоба от старых к новым: statuses[-1] - последний прогон."""
ago = step * len(statuses)
out = []
for i, status in enumerate(statuses):
out.append(run(start + i, job, status, ago=ago - step * i))
return out
def state(tmp_path, **kw):
return Runs(path=tmp_path / "прогоны.json", **kw)
def test_three_in_a_row_is_an_alarm(tmp_path):
rows = series("вайбграм", ["done", "failed", "failed", "failed"])
report = look(state(tmp_path), rows, now=NOW, me="прогоны")
assert not report.empty and report.seen == 4
(tail,) = report.alarms
assert tail.job == "вайбграм" and tail.count == 3
assert tail.since == NOW - timedelta(minutes=30)
assert not tail.capped
text = alarm_text(report)
assert "джоб `вайбграм` лежит 30 минут, 3 прогона подряд" in text
assert "с 22.09 15:12 UTC" in text
# Первая строка ошибки, а не трейсбек
assert "HubError: vibegram 530 bad_json" in text
assert "Traceback" not in text and "scheduler.py" not in text
def test_two_in_a_row_stays_quiet(tmp_path):
rows = series("вайбграм", ["done", "failed", "failed"])
report = look(state(tmp_path), rows, now=NOW, me="прогоны")
assert not report.alarms and not report.clears and not report.empty
# Одиночное падение - тем более молчание
once = look(state(tmp_path), series("логи", ["failed"]), now=NOW, me="прогоны")
assert not once.alarms
def test_only_the_tail_counts(tmp_path):
# Падало много, но последний прогон прошёл - джоб не лежит
rows = series("логи", ["failed", "failed", "failed", "failed", "done"])
assert not look(state(tmp_path), rows, now=NOW, me="прогоны").alarms
def test_alarm_not_repeated_within_a_day(tmp_path):
rows = series("вайбграм", ["done", "failed", "failed", "failed", "failed"])
told = state(tmp_path, alerted={"вайбграм": NOW - timedelta(hours=5)})
quiet = look(told, rows, now=NOW, me="прогоны")
assert not quiet.alarms
assert "уже говорили меньше суток назад" in quiet.note
older = state(tmp_path, alerted={"вайбграм": NOW - timedelta(hours=25)})
assert look(older, rows, now=NOW, me="прогоны").alarms
def test_repair_clears_and_resets(tmp_path):
rows = series("вайбграм", ["failed", "failed", "failed", "done"])
told = state(tmp_path, alerted={"вайбграм": NOW - timedelta(hours=2)})
report = look(told, rows, now=NOW, me="прогоны")
assert not report.alarms
(clear,) = report.clears
assert clear.job == "вайбграм" and clear.count == 0 and clear.status == "done"
assert "отбой: джоб `вайбграм` снова проходит (done)" in clear_text(report)
# Сброс делает джоб; после него тот же вход снова может поднять тревогу
del told.alerted["вайбграм"]
again = look(told, series("вайбграм", ["failed"] * 3), now=NOW, me="прогоны")
assert again.alarms
def test_repair_is_told_once_not_every_run(tmp_path):
rows = series("вайбграм", ["failed", "failed", "failed", "done"])
clean = state(tmp_path)
assert not look(clean, rows, now=NOW, me="прогоны").clears
def test_flapping_job_is_a_clear_not_an_alarm(tmp_path):
# После тревоги джоб моргает: хвост короче порога - он больше не лежит
rows = series("вайбграм", ["done", "done", "failed", "failed"])
told = state(tmp_path, alerted={"вайбграм": NOW - timedelta(hours=2)})
report = look(told, rows, now=NOW, me="прогоны")
(clear,) = report.clears
assert clear.count == 2
assert f"порог {STREAK}" in clear_text(report)
def test_streak_running_past_the_window_says_at_least(tmp_path):
rows = series("вайбграм", ["failed"] * 144, step=10)
report = look(state(tmp_path), rows, now=NOW, me="прогоны")
(tail,) = report.alarms
assert tail.capped and tail.count == 144
text = alarm_text(report)
assert "лежит 24 часа и дольше, 144 прогона подряд" in text
assert "это всё окно суток, серия началась раньше" in text
def test_empty_window_is_its_own_alarm(tmp_path):
report = look(state(tmp_path), [], now=NOW, me="прогоны")
assert report.empty and not report.alarms and report.seen == 0
assert "не отработал ни один джоб" in empty_text(report)
assert "планировщике" in empty_text(report)
# И повторяется не чаще раза в сутки
told = state(tmp_path, empty_alerted=NOW - timedelta(hours=5))
assert not look(told, [], now=NOW, me="прогоны").empty
stale = state(tmp_path, empty_alerted=NOW - timedelta(hours=25))
assert look(stale, [], now=NOW, me="прогоны").empty
def test_own_runs_do_not_count_as_life_or_as_illness(tmp_path):
mine = series("прогоны", ["failed", "failed", "failed", "done"])
report = look(state(tmp_path), mine, now=NOW, me="прогоны")
# Свои прогоны в окне есть, чужих нет: это всё равно пустое окно
assert report.empty and not report.alarms and report.mine == 4
assert "их 4" in empty_text(report)
def test_forgotten_job_leaves_the_state_silently(tmp_path):
rows = series("логи", ["done", "done"])
told = state(
tmp_path,
alerted={
"исчезнувший": NOW - timedelta(days=8),
"недавний": NOW - timedelta(hours=3),
},
)
report = look(told, rows, now=NOW, me="прогоны")
assert not report.clears # починки мы не видели, «отбой» врать не о чем
assert "исчезнувший" not in told.alerted
assert "недавний" in told.alerted
def test_several_broken_jobs_come_in_one_alarm(tmp_path):
rows = [
*series("вайбграм", ["failed"] * 4, start=100),
*series("логи", ["failed"] * 3, start=200),
*series("досуг", ["done"], start=300),
]
report = look(state(tmp_path), rows, now=NOW, me="прогоны")
assert len(report.alarms) == 2
assert report.alarms[0].job == "вайбграм" # длиннее серия - выше в тексте
assert alarm_text(report).count("\n") == 1
def test_first_line_masks_secrets_and_cuts_the_tail():
assert first_line(None) == "без текста ошибки"
assert first_line("") == "без текста ошибки"
# Токен в строке ошибки не уезжает человеку
assert "s3cr3tvalue" not in first_line("RuntimeError: MSOS_MCP=s3cr3tvalue died")
assert "MSOS_MCP=<…>" in first_line("RuntimeError: MSOS_MCP=s3cr3tvalue died")
assert "hunter2" not in first_line("ошибка: password=hunter2")
assert "abc123" not in first_line("GET https://hub/api?t=abc123 -> 500")
assert "https://hub/api?<…>" in first_line("GET https://hub/api?t=abc123 -> 500")
assert "Bearer abcdefghij" not in first_line("Authorization: Bearer abcdefghij")
# Длинная строка режется
long = first_line("E: " + "x" * 500)
assert len(long) == 160 and long.endswith("…")
# Имя без запаха секрета остаётся читаемым
assert mask("status=530 job=вайбграм") == "status=530 job=вайбграм"
def test_state_survives_a_round_trip(tmp_path):
saved = state(
tmp_path,
alerted={"вайбграм": NOW, "логи": NOW - timedelta(hours=3)},
empty_alerted=NOW,
)
saved.save()
back = Runs.load(saved.path)
assert back.alerted["вайбграм"] == NOW
assert back.alerted["логи"] == NOW - timedelta(hours=3)
assert back.empty_alerted == NOW
assert Runs.load(tmp_path / "нет.json").alerted == {}
(tmp_path / "мусор.json").write_text("не json", encoding="utf-8")
assert Runs.load(tmp_path / "мусор.json").empty_alerted is None
class FakeRun:
def __init__(self, database=None, *, name="прогоны"):
self.job = SimpleNamespace(name=name, cron="27 * * * *")
self.conversations = SimpleNamespace(db=database)
self.spawned = []
async def spawn_job(self, *, agent, text, title=None):
self.spawned.append((agent, title, text))
return None
async def make_db(tmp_path, rows):
from beaver_gateway.storage.db import Database
from beaver_gateway.storage.models import JobRunRecord
database = Database(f"sqlite+aiosqlite:///{tmp_path / 'journal.db'}")
await database.create_all()
async with database.session() as session:
for row in rows:
session.add(row)
await session.commit()
return database
def record(job, status, *, ago, error=None):
from beaver_gateway.storage.models import JobRunRecord
started = (datetime.now(UTC) - timedelta(minutes=ago)).replace(tzinfo=None)
return JobRunRecord(
job=job,
trigger="cron",
started_at=started,
finished_at=started,
status=status,
error=error if error is not None or status != "failed" else BOOM,
)
async def test_job_reads_the_real_journal_and_spawns(tmp_path, monkeypatch):
from beaver_agent.jobs import runs as job
monkeypatch.setattr(job, "STATE_FILE", tmp_path / "прогоны.json")
database = await make_db(
tmp_path,
[
record("вайбграм", "failed", ago=30),
record("вайбграм", "failed", ago=20),
record("вайбграм", "failed", ago=10),
record("досуг", "done", ago=5),
record("прогоны", "done", ago=3),
# Старше суток - в окно не попадает
record("логи", "failed", ago=60 * 30),
],
)
run_ = FakeRun(database)
await job.watch_runs(cast("Any", run_))
assert len(run_.spawned) == 1
agent, title, text = run_.spawned[0]
assert agent == "beaver-triage" and title == "прогоны"
assert "джоб `вайбграм` лежит" in text and "3 прогона подряд" in text
assert "логи" not in text # за окном
assert "прогоны" not in text.split("\n")[1] # сам себя не считает
# Тревога записана - следующий прогон в те же сутки молчит
assert Runs.load(tmp_path / "прогоны.json").alerted["вайбграм"] is not None
await job.watch_runs(cast("Any", run_))
assert len(run_.spawned) == 1
await database.dispose()
async def test_job_says_otboy_when_the_job_recovers(tmp_path, monkeypatch):
from beaver_agent.jobs import runs as job
monkeypatch.setattr(job, "STATE_FILE", tmp_path / "прогоны.json")
Runs(
path=tmp_path / "прогоны.json",
alerted={"вайбграм": datetime.now(UTC) - timedelta(hours=2)},
).save()
database = await make_db(
tmp_path,
[
record("вайбграм", "failed", ago=30),
record("вайбграм", "failed", ago=20),
record("вайбграм", "done", ago=10),
],
)
run_ = FakeRun(database)
await job.watch_runs(cast("Any", run_))
assert len(run_.spawned) == 1
_, _, text = run_.spawned[0]
assert "отбой: джоб `вайбграм` снова проходит" in text
assert 'urgency="normal"' in text # отбой не будит
assert Runs.load(tmp_path / "прогоны.json").alerted == {}
# Сброшено - второй «отбой» не уходит
await job.watch_runs(cast("Any", run_))
assert len(run_.spawned) == 1
await database.dispose()
async def test_job_alarms_on_an_empty_journal(tmp_path, monkeypatch):
from beaver_agent.jobs import runs as job
monkeypatch.setattr(job, "STATE_FILE", tmp_path / "прогоны.json")
database = await make_db(tmp_path, [record("прогоны", "done", ago=5)])
run_ = FakeRun(database)
await job.watch_runs(cast("Any", run_))
assert len(run_.spawned) == 1
_, _, text = run_.spawned[0]
assert "нет ни одной записи" in text and "не отработал ни один джоб" in text
assert Runs.load(tmp_path / "прогоны.json").empty_alerted is not None
await database.dispose()
async def test_unreadable_journal_is_not_a_reading(tmp_path, monkeypatch):
from beaver_agent.jobs import runs as job
class Dead:
def session(self):
msg = "база не отвечает"
raise RuntimeError(msg)
monkeypatch.setattr(job, "STATE_FILE", tmp_path / "прогоны.json")
run_ = FakeRun(Dead())
await job.watch_runs(cast("Any", run_))
assert not run_.spawned
assert not (tmp_path / "прогоны.json").exists()
@pytest.mark.parametrize("name", ["прогоны", "иначе-названный"])
async def test_the_watcher_never_reports_itself(tmp_path, monkeypatch, name):
from beaver_agent.jobs import runs as job
monkeypatch.setattr(job, "STATE_FILE", tmp_path / "прогоны.json")
database = await make_db(
tmp_path,
[
record(name, "failed", ago=30),
record(name, "failed", ago=20),
record(name, "failed", ago=10),
record("досуг", "done", ago=5),
],
)
run_ = FakeRun(database, name=name)
await job.watch_runs(cast("Any", run_))
assert not run_.spawned
await database.dispose()
Generated
+8 -9
View File
@@ -3,8 +3,8 @@ revision = 3
requires-python = ">=3.13"
resolution-markers = [
"python_full_version >= '3.14' and extra != 'extra-12-beaver-agent-local' and extra == 'extra-12-beaver-agent-prod'",
"python_full_version < '3.14' and extra != 'extra-12-beaver-agent-local' and extra == 'extra-12-beaver-agent-prod'",
"python_full_version >= '3.14' and extra == 'extra-12-beaver-agent-local' and extra != 'extra-12-beaver-agent-prod'",
"python_full_version < '3.14' and extra != 'extra-12-beaver-agent-local' and extra == 'extra-12-beaver-agent-prod'",
"python_full_version < '3.14' and extra == 'extra-12-beaver-agent-local' and extra != 'extra-12-beaver-agent-prod'",
"extra != 'extra-12-beaver-agent-local' and extra != 'extra-12-beaver-agent-prod'",
]
@@ -390,7 +390,7 @@ requires-dist = [
{ name = "anthropic", specifier = ">=0.103.0" },
{ name = "anyio", specifier = ">=4.13.0" },
{ name = "argon2-cffi", specifier = ">=25.1.0" },
{ name = "claude-agent-sdk", specifier = ">=0.2.150" },
{ name = "claude-agent-sdk", specifier = ">=0.2.157" },
{ name = "croniter", specifier = ">=6.2.4" },
{ name = "fastapi", specifier = ">=0.136.1" },
{ name = "fastmcp", specifier = ">=3.3.1" },
@@ -502,7 +502,7 @@ wheels = [
[[package]]
name = "claude-agent-sdk"
version = "0.2.150"
version = "0.2.157"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
@@ -510,13 +510,12 @@ dependencies = [
{ name = "mcp" },
{ name = "sniffio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/6d/1e/99bababecf24a4a55a0775665ff0be010aff26f653fc9be2f4f17a0b5ef0/claude_agent_sdk-0.2.150.tar.gz", hash = "sha256:3856e932f8d45514ddf5ddd540fb844678bb11638cabf9b939ce8b708e4eb352", size = 344692, upload-time = "2026-09-01T18:07:15.284Z" }
sdist = { url = "https://files.pythonhosted.org/packages/b4/e3/444df1691c864a8349067686719b43e714fc43d3853fb1b2ebc2097e2a6e/claude_agent_sdk-0.2.157.tar.gz", hash = "sha256:4ce6f41bc965fcb4a9d11bd7a3cac266975c75b632f3d74a26f55f0fffb3160a", size = 346579, upload-time = "2026-09-18T18:21:10.306Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c3/13/b5b86d90c95e8a691143eeb45bbf2e1c250324817980464d6ea63ef0f789/claude_agent_sdk-0.2.150-py3-none-macosx_11_0_arm64.whl", hash = "sha256:8f58d746f3552faeac52883b5a502a8d9b5aef067ea0b68fae5c9bae0f39d73d", size = 85396845, upload-time = "2026-09-01T18:07:19.074Z" },
{ url = "https://files.pythonhosted.org/packages/d9/8f/c17da4fa9cd8bddb8ac1538456dd0fde3a43abb1dfc28ca448f85e2a10d3/claude_agent_sdk-0.2.150-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:fee3ae2dd457742d82070b5fa122070ee8e2e80b4f9b37a57ff0a8261ade656d", size = 89904483, upload-time = "2026-09-01T18:07:22.671Z" },
{ url = "https://files.pythonhosted.org/packages/8d/95/5b33717b807c6fbe29fca55a246eab9a09a80c9dd288ffeb31f05681b7fc/claude_agent_sdk-0.2.150-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:813535a0267271d91af29c456ec26137b34a8b7bc066647ea59efba6e3c26c93", size = 95270310, upload-time = "2026-09-01T18:07:26.61Z" },
{ url = "https://files.pythonhosted.org/packages/9f/de/5a8b59e6a713ea3f4be88816cfd487b1a5b08c8ad9f042abe56d2decc184/claude_agent_sdk-0.2.150-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:cb65ff33060baa098b420232c630fa287d0f95e9469e7ceb706d0b7d8841888e", size = 95460277, upload-time = "2026-09-01T18:07:31.092Z" },
{ url = "https://files.pythonhosted.org/packages/dd/42/d147578a0df16c1b4b0deaebfb3d25e590ad5c16cb6b59a68f9dbb618be3/claude_agent_sdk-0.2.150-py3-none-win_amd64.whl", hash = "sha256:3b517a21c723854de778b38fa51660c5b68ee5fb6f28b2abf291a51471a7b378", size = 98078644, upload-time = "2026-09-01T18:07:35.734Z" },
{ url = "https://files.pythonhosted.org/packages/ec/e8/3e9cc23eb5a5a01a0e2ee931a17db2785f95407525d50366af06a4a5469c/claude_agent_sdk-0.2.157-py3-none-macosx_11_0_arm64.whl", hash = "sha256:dd6a2c7aa1901e43b827411d62f2327aed82f53ed8ca6b076eaa5be4a1fc681f", size = 92744001, upload-time = "2026-09-18T18:21:15.969Z" },
{ url = "https://files.pythonhosted.org/packages/49/eb/e5095703a8efbe504ec468cab039543caabfc0ea3327c735a3d1b60e57ac/claude_agent_sdk-0.2.157-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:ebf3f514c61db9be01bbf6d1acb0d993247340583b3559d2df75e3822f618bbb", size = 97352326, upload-time = "2026-09-18T18:21:21.007Z" },
{ url = "https://files.pythonhosted.org/packages/d6/f4/64505beb3c757b863753d6556539554704ec9168cb96454dc6463b14df85/claude_agent_sdk-0.2.157-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:a58089e0c11364db2ec7a139e09b36a4fb9c95817a758bee7b28e0434f55c0b6", size = 102701030, upload-time = "2026-09-18T18:21:27.065Z" },
{ url = "https://files.pythonhosted.org/packages/03/8f/2a7c121469fd16d1d6465bec180726c54f6e151b12e50835283b4285d1ce/claude_agent_sdk-0.2.157-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:bd126b2edee3a247b2c7c4cee1f5a3ea68ef748288ec0650db88dd3918e5471a", size = 102830653, upload-time = "2026-09-18T18:21:32.948Z" },
]
[[package]]