Compare commits
7
Commits
58338f132a
...
ecd26a5457
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ecd26a5457 | ||
|
|
60f11eea5b | ||
|
|
415f49ccd7 | ||
|
|
6e28ff1e5b | ||
|
|
d8ab6bb72c | ||
|
|
f6cb17acaf | ||
|
|
0475e09c81 |
No files matched your search
@@ -114,6 +114,9 @@ VIBEGRAM_REPO=
|
|||||||
# Токен GitHub с доступом к репе (приватная); пусто - публичная
|
# Токен GitHub с доступом к репе (приватная); пусто - публичная
|
||||||
VIBEGRAM_REPO_TOKEN=
|
VIBEGRAM_REPO_TOKEN=
|
||||||
|
|
||||||
|
VIBEGRAM_HB_TOKEN=
|
||||||
|
VIBEGRAM_HB_NICK=
|
||||||
|
|
||||||
### Досуг (ежечасный тик мастера, с S13) ######################################
|
### Досуг (ежечасный тик мастера, с S13) ######################################
|
||||||
|
|
||||||
# Час активного окна (12:00-03:59) и четыре часа тихого (04:00-11:59) будят
|
# Час активного окна (12:00-03:59) и четыре часа тихого (04:00-11:59) будят
|
||||||
|
|||||||
@@ -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
|
Switches: `досуг: выкл` as the first line of the file for every day, and
|
||||||
`LEISURE=off` in `.env` to drop the job entirely.
|
`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`)
|
## Policy (`beaver_agent/policy.py`)
|
||||||
|
|
||||||
`bypassPermissions` everywhere; the boundary is `PreToolUse` rules declared
|
`bypassPermissions` everywhere; the boundary is `PreToolUse` rules declared
|
||||||
|
|||||||
@@ -134,14 +134,14 @@ def raycast(name: str, model: str, effort: str) -> RaycastAgent:
|
|||||||
|
|
||||||
|
|
||||||
agents: list[BaseAgent] = [
|
agents: list[BaseAgent] = [
|
||||||
dispatcher("beaver-dispatcher", "claude-opus-5", "high"),
|
dispatcher("beaver-dispatcher", "claude-opus-5-5", "high"),
|
||||||
deep("beaver-opus-high", "claude-opus-5", "high"),
|
deep("beaver-opus-high", "claude-opus-5-5", "high"),
|
||||||
deep("beaver-opus-medium", "claude-opus-5", "medium"),
|
deep("beaver-opus-medium", "claude-opus-5-5", "medium"),
|
||||||
deep("beaver-opus-xhigh", "claude-opus-5", "xhigh"),
|
deep("beaver-opus-xhigh", "claude-opus-5-5", "xhigh"),
|
||||||
deep("beaver-fable-high", "claude-fable-5-1", "high"),
|
deep("beaver-fable-high", "claude-fable-5-1", "high"),
|
||||||
deep("beaver-fable-medium", "claude-fable-5-1", "medium"),
|
deep("beaver-fable-medium", "claude-fable-5-1", "medium"),
|
||||||
distiller("beaver-distiller", "claude-opus-5", "medium"),
|
distiller("beaver-distiller", "claude-opus-5-5", "medium"),
|
||||||
curator("beaver-curator", "claude-opus-5", "high"),
|
curator("beaver-curator", "claude-opus-5-5", "high"),
|
||||||
triage("beaver-triage", "claude-sonnet-5", "low"),
|
triage("beaver-triage", "claude-sonnet-5", "low"),
|
||||||
voice(VOICE, "claude-haiku-4-5-20251001"),
|
voice(VOICE, "claude-haiku-4-5-20251001"),
|
||||||
raycast("beaver-gemini-pro-high", "google-gemini-3.1-pro", "high"),
|
raycast("beaver-gemini-pro-high", "google-gemini-3.1-pro", "high"),
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -19,6 +20,7 @@ from beaver_agent.hands.vibegram import Vibegram
|
|||||||
from beaver_agent.vault import TZ
|
from beaver_agent.vault import TZ
|
||||||
|
|
||||||
env = os.environ.get
|
env = os.environ.get
|
||||||
|
_log = logging.getLogger(__name__)
|
||||||
|
|
||||||
obsidian = McpServer.stdio(
|
obsidian = McpServer.stdio(
|
||||||
name="obsidian-fs",
|
name="obsidian-fs",
|
||||||
@@ -98,6 +100,29 @@ vibegram = (
|
|||||||
)
|
)
|
||||||
vibegram_exposed = (ExposedMcp(name="vibegram"),) if VIBEGRAM else ()
|
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 = (
|
t3code = (
|
||||||
McpServer.http(name="t3code", url=env("T3CODE_MCP", "http://t3code-mcp:8000/mcp"))
|
McpServer.http(name="t3code", url=env("T3CODE_MCP", "http://t3code-mcp:8000/mcp"))
|
||||||
if "t3" in env("COMPOSE_PROFILES", "").split(",")
|
if "t3" in env("COMPOSE_PROFILES", "").split(",")
|
||||||
|
|||||||
@@ -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}, а не наше: разбудить владельца имеет смысл "
|
||||||
|
"только чтобы он написал соседу по своему каналу. Повторно об этом "
|
||||||
|
"молчании мы скажем не раньше чем через сутки."
|
||||||
|
)
|
||||||
@@ -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, и не заведена ли база заново - на пустой базе эта же "
|
||||||
|
"тревога выглядит так же."
|
||||||
|
)
|
||||||
@@ -151,6 +151,15 @@ class Vibegram:
|
|||||||
)
|
)
|
||||||
return fresh
|
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:
|
def mentioned(self, events: list[Event]) -> bool:
|
||||||
needle = f"@{self.nick}".lower()
|
needle = f"@{self.nick}".lower()
|
||||||
return any(needle in e.text.lower() for e in events)
|
return any(needle in e.text.lower() for e in events)
|
||||||
|
|||||||
@@ -8,11 +8,13 @@ from beaver_gateway.jobs.scheduler import Job
|
|||||||
|
|
||||||
from beaver_agent.jobs.closing import close_idle
|
from beaver_agent.jobs.closing import close_idle
|
||||||
from beaver_agent.jobs.deploy import deploy
|
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.komodo import komodo_alert
|
||||||
from beaver_agent.jobs.leisure import tick
|
from beaver_agent.jobs.leisure import tick
|
||||||
from beaver_agent.jobs.logs import logs_watch
|
from beaver_agent.jobs.logs import logs_watch
|
||||||
from beaver_agent.jobs.memory import curate, memory
|
from beaver_agent.jobs.memory import curate, memory
|
||||||
from beaver_agent.jobs.rotation import rotate
|
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.t3code import t3code_event
|
||||||
from beaver_agent.jobs.vibegram import vibegram
|
from beaver_agent.jobs.vibegram import vibegram
|
||||||
from beaver_agent.jobs.voice import rotate_voice
|
from beaver_agent.jobs.voice import rotate_voice
|
||||||
@@ -27,6 +29,22 @@ jobs = [
|
|||||||
*([] if LEISURE_OFF else [Job("досуг", tick, cron="7 * * * *")]),
|
*([] if LEISURE_OFF else [Job("досуг", tick, cron="7 * * * *")]),
|
||||||
Job("вайбграм", vibegram, cron="*/10 * * * *", critical=False),
|
Job("вайбграм", vibegram, cron="*/10 * * * *", critical=False),
|
||||||
Job("закрытие", close_idle, cron="20 4 * * *", 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("память", memory, cron="30 4 * * 0", critical=False),
|
||||||
Job("голосовой", rotate_voice, cron="5 4 * * *", critical=False),
|
Job("голосовой", rotate_voice, cron="5 4 * * *", critical=False),
|
||||||
Job("куратор", curate, cron="0 2,8,12,16,20 * * *", critical=False),
|
Job("куратор", curate, cron="0 2,8,12,16,20 * * *", critical=False),
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
Executable
+87
@@ -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())
|
||||||
@@ -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
@@ -1,4 +1,6 @@
|
|||||||
|
import shutil
|
||||||
import tempfile
|
import tempfile
|
||||||
|
from collections.abc import Iterator
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -15,13 +17,16 @@ from beaver_agent.policy import (
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def vault() -> Path:
|
def vault() -> Iterator[Path]:
|
||||||
root = Path(tempfile.mkdtemp(prefix="beaver-vault-"))
|
root = Path(tempfile.mkdtemp(prefix="beaver-vault-"))
|
||||||
for d in ("мета/бобер", "💬 чаты", "📅 дни", "👤 люди"):
|
try:
|
||||||
(root / d).mkdir(parents=True)
|
for d in ("мета/бобер", "💬 чаты", "📅 дни", "👤 люди"):
|
||||||
(root / "💬 чаты/старый.md").write_text("x")
|
(root / d).mkdir(parents=True)
|
||||||
(root / "📅 дни/2026-08-29.md").write_text("x")
|
(root / "💬 чаты/старый.md").write_text("x")
|
||||||
return root
|
(root / "📅 дни/2026-08-29.md").write_text("x")
|
||||||
|
yield root
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(root, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -3,8 +3,8 @@ revision = 3
|
|||||||
requires-python = ">=3.13"
|
requires-python = ">=3.13"
|
||||||
resolution-markers = [
|
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'",
|
||||||
|
"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'",
|
"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 = "anthropic", specifier = ">=0.103.0" },
|
||||||
{ name = "anyio", specifier = ">=4.13.0" },
|
{ name = "anyio", specifier = ">=4.13.0" },
|
||||||
{ name = "argon2-cffi", specifier = ">=25.1.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 = "croniter", specifier = ">=6.2.4" },
|
||||||
{ name = "fastapi", specifier = ">=0.136.1" },
|
{ name = "fastapi", specifier = ">=0.136.1" },
|
||||||
{ name = "fastmcp", specifier = ">=3.3.1" },
|
{ name = "fastmcp", specifier = ">=3.3.1" },
|
||||||
@@ -502,7 +502,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "claude-agent-sdk"
|
name = "claude-agent-sdk"
|
||||||
version = "0.2.150"
|
version = "0.2.157"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "anyio" },
|
{ name = "anyio" },
|
||||||
@@ -510,13 +510,12 @@ dependencies = [
|
|||||||
{ name = "mcp" },
|
{ name = "mcp" },
|
||||||
{ name = "sniffio" },
|
{ 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 = [
|
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/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/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/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/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/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/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/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" },
|
||||||
{ 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" },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
Reference in new issue
Block a user