Compare commits

...
21 Commits
Author SHA1 Message Date
hh 6db6c7b6ec feat(jobs): t3code events go back to the dispatching conversation, quiet branches close at night 2026-10-23 22:25:12 +02:00
hh 2bf2326178 feat(agents,jobs): dispatcher back to high effort, a manual rotation run forces the rotation 2026-10-19 13:51:37 +02:00
hh 1d8b8c68a3 docs(*): secrets key in the env example and the secrets page in the readme 2026-10-18 16:18:26 +02:00
hh fc97463f0f feat(agents): dispatcher runs at xhigh effort 2026-10-13 02:24:14 +02:00
hh 3e62529a0e chore(scripts): one-off split of the eternal memory files by day 2026-10-13 01:23:21 +02:00
hh fbfe39a968 feat(agents): reasoning arrives as text for opus and fable agents 2026-10-13 01:23:20 +02:00
hh eba0cc6168 feat(jobs): leisure ticks name the day's diary and journal, two own hours stay empty and carry an echo 2026-10-13 01:23:20 +02:00
hh dea793dbd7 feat(memory,jobs): replies and curator runs go to daily files 2026-10-13 01:23:20 +02:00
hh 0273f29589 feat(t3): background machine is dell-dev, the vm that runs t3 after the dell move 2026-10-08 00:13:34 +02:00
hh 9a2f5c2b2b feat(t3): threads on mac and dell start on claude-opus-5-5 2026-10-07 02:53:05 +02:00
hh 9bf897a809 feat(hands): vibegram read takes since and last for a non-consuming look at history 2026-09-30 23:33:13 +00:00
hh 78a0293819 fix(hands): watchdog seen moves only on a new peer beat, tracked by message id 2026-09-27 18:20:50 +00:00
hh a39e0b27af feat(hands,jobs): drill days skip the beat and ack carries the alert decision 2026-09-26 01:52:53 +00:00
hh 47c31ac356 fix(hands,jobs): watchdog window reads the room tail, not the pending cursor 2026-09-23 18:18:27 +00:00
hh 91bd50a66d feat(hands,jobs): job-run watchdog looks a week back and alarms on jobs that stopped running 2026-09-22 20:15:15 +02:00
hh 426b4055f4 docs(env): SEND_FILE_ROOTS in the agent's .env.example
It was only documented in beaver-gateway/.env.example, while the server's
.env is assembled from this one.
2026-09-22 20:09:29 +02:00
hh ecd26a5457 feat(agents): deep chats, distiller and curator move to claude-opus-5-5 2026-09-22 18:02:07 +00:00
hh 60f11eea5b feat(agents): dispatcher moves to claude-opus-5-5 2026-09-22 17:35:10 +00:00
hh 415f49ccd7 feat(hands,jobs): job-run watchdog reads job_runs and alarms on three failures in a row 2026-09-22 17:16:17 +00:00
hh 6e28ff1e5b fix(scripts): vibegram join sends a user-agent, cloudflare blocks the default 2026-09-22 14:25:35 +00:00
hh d8ab6bb72c fix(jobs): watchdog checks every six hours, the beat stays daily 2026-09-22 14:25:35 +00:00
31 changed files with 2861 additions and 130 deletions

No files matched your search

+18 -4
View File
@@ -6,7 +6,7 @@
### Compose ###################################################################
# Какие сервисы поднимать: db (postgres), gateway, obsidian (obsidian-headless),
# t3 (t3code-mcp - руки диспетчера на маке и dell), vibegram (клон репы комнаты)
# t3 (t3code-mcp - руки диспетчера на маке и dell-dev), vibegram (клон репы комнаты)
COMPOSE_PROFILES=db,gateway,obsidian,t3,vibegram
# Ветка/тег beaver-gateway, из которого собирается образ шлюза
@@ -31,6 +31,11 @@ ADMIN_PASS=
# Подпись session-cookie admin UI
SESSION_SECRET=
# Мастер-ключ хранилища секретов (admin → System → Secrets): openssl rand -hex 32.
# Шифрует таблицу secrets в postgres, поэтому дамп базы без него закрыт.
# В env модели не попадает. Потерян или сменён - секреты задаются заново.
SECRETS_KEY=
### Claude ####################################################################
# OAuth-токен подписки для Claude Agent SDK внутри контейнера.
@@ -54,6 +59,15 @@ RAYCAST_DEVICE_ID=
# rca_... из десктоп-приложения Raycast (см. raycast-api)
RAYCAST_BEARER=
### Отдача файлов человеку (send_file) ########################################
# Откуда тулза send_file имеет право брать файлы (несколько - через ':').
# Папка входящих вложений разрешена всегда, её дописывает сам фронтенд.
# Каталог должен быть виден контейнеру gateway по ТОМУ ЖЕ пути, что снаружи
# (см. монтирование /mnt/hole/shared в docker-compose.yml).
# Пусто = /mnt/hole/shared, дефолт шлюза
SEND_FILE_ROOTS=/mnt/hole/shared
### MCP, к которым ходят агенты ###############################################
# Firefly III: адрес и Personal Access Token
@@ -76,9 +90,9 @@ MSOS_MCP=
# t3code-mcp - сервис compose (профиль t3), машины и allowlist - ./t3code.toml.
# На каждой машине: `t3 auth session issue --token-only --ttl 365d --label beaver`
T3_MAC_TOKEN=
T3_DELL_TOKEN=
# tailnet-адрес самого dell: контейнер t3code-mcp иначе резолвит `dell` в 127.0.1.1
T3_DELL_ADDR=
T3_DELL_DEV_TOKEN=
# tailnet-адрес dell-dev (дев-VM на делле), прописывается контейнеру в hosts
T3_DELL_DEV_ADDR=
# Токен gateway со scope `api` (admin → Tokens) - им t3code-mcp шлёт события в /hooks/t3code
T3CODE_MCP_GATEWAY_TOKEN=
# Ветка/тег t3code-mcp, из которого собирается образ
+115 -8
View File
@@ -71,6 +71,12 @@ read-write (it is the dispatcher's home), and `beaver_agent/policy.py` keeps
it out of `.obsidian`, `мета` outside `мета/бобер`, and from deleting
anything but its own files.
Keys the agent needs for its own scripts (an RPC key, a service account
file) go into **System → Secrets** in the admin, not into files in the
container: they are sealed with `SECRETS_KEY` in Postgres, never shown back,
and reach a command only through `secret run NAME -- cmd` (see the `secrets`
skill under `мета/бобер/скиллы/общие/`).
### 4. Mint a token
Open admin at `http://localhost:62990/` (or `https://<DOMAIN>/` if Caddy), sign in with `ADMIN_USER` / `ADMIN_PASS`, go to **Tokens → Create**, scope `*` for first run.
@@ -208,14 +214,17 @@ Switches: `досуг: выкл` as the first line of the file for every day, an
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. If the neighbour goes quiet past the threshold, *their* human is the
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
the one the dispatcher's `vibegram` tool and the triage job share: one client
for both rooms would mean the triage job eats the heartbeats off the shared
`/api/pending` cursor, 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
@@ -226,19 +235,40 @@ are no defaults anywhere in it.
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.
- `сторож` (17:42 local) asks for an explicit window (`pending(limit=25)`),
finds the newest `hb claude-kai …`, and compares its age against
- `сторож` (`:42` every six hours) reads a window - **the last 25 messages of
that room** (`Vibegram.window`) - 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 run prints
`ack claude-kai last=… seen=… res=… win=… armed=… thr=… alert=…` 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.
- `alert=` is what the **instrument decided** in that run, not what reached a
human: `fired` - an alarm (`silence` or `unarmed`) was taken and handed on,
`held` - the threshold was crossed but the alarm was suppressed as a repeat
(the previous one went out less than a day ago), `none` - no alarm. Without
it the neighbour saw the arithmetic and had to guess what we made of it.
Delivery is decided past the watchdog, by the master thread and the
dispatcher, and its failure shows up in the log, not here.
- 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.
- The window is **not** `/api/pending`. That endpoint is a cursor: it hands a
line over once and never again, so the neighbour's beat showed up in one run
and was gone from the next one's window on a perfectly live room - the room
held it the whole time. The tail comes from `/api/state?view=…` instead,
which is already scoped to the room (hub event ids run across all rooms) and
pages **forward** in chunks of 200 from `after=<id>`; the watchdog walks the
history and keeps the last 25 *messages*, joins and leaves not counted.
- **An unread window is not a reading.** If the history could not be walked to
its end (the page budget ran out), the missing part is exactly the newest,
so `win=25/partial` goes into the `ack`, the log says `окно не дочитано`, and
no alarm is taken in that run: "nobody in the window" and "the window was
never finished" must not look the same.
- 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.
@@ -251,6 +281,15 @@ are no defaults anywhere in it.
State between runs is `мета/бобер/.состояние/сторож.json`; a lost file means
the ring disarms itself and starts the seven-day count again.
**Drills.** Next to that state file lives `мета/бобер/.состояние/сторож-учения.txt`
- a list of drill dates, one `YYYY-MM-DD` per line, each the local date of the
run in the scheduler's zone (`Europe/Warsaw`). On a date in that list `отметка`
sends nothing into the room and says so in the log only: a skipped beat is the
drill, and any text there ("skipping today") would call it off. No file, an
empty one or an unreadable one means no drill and the beat goes out as usual.
The dates are not in the code, and adding one needs no redeploy - a ring only
ever tested on "all alive" has never shown that the other side's alarm fires.
Setup is two variables in `.env` and one manual step:
```bash
@@ -265,6 +304,74 @@ 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 7 days 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.
The window is a week, not a day, so a daily job (`отметка`, `закрытие`,
`голосовой`) gets its three failures in a row in three days.
- **A job that stopped running is an alarm too, with its own text**: «джоб
`закрытие` не запускался 2 суток при периоде 24 часа». For every job with a
cron, the period comes from the registered cron (`run.scheduler.jobs`) via
the same `every_from_cron` the neighbour watchdog uses, and the last run is
taken from the whole journal, not the window. The threshold is **two
periods**: one missed fire happens honestly (a redeploy landed on the cron
minute, and the scheduler does not catch up), two in a row do not. It is
never finer than two of the watchdog's own periods (2 hours), because an
hourly watcher cannot tell a shorter gap from its own blindness between
runs. Time when the scheduler was not running non-critical jobs at all is
not counted as a miss: before the process started, and while the
watchdog's own hourly chain is broken (the gateway was down, or the budget
was throttled and non-critical crons were deferred without a record). Same
discipline as failures: once a day per job, and an `отбой` when a run shows
up.
- What silence does not cover, on purpose: jobs without a cron (webhooks,
events) are not measured at all; a job that has never appeared in the
journal waits silently for its first row; and a cron that does not reduce to
a regular period (`куратор`, `0 2,8,12,16,20 * * *`) is skipped and named in
the log as unguarded every run, because any number put in its place would
be made up. `куратор` still falls under the failure count.
- **An empty answer is a statement about the query, not about health.** Zero
rows in the last 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, separately for failures and for silence). 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.
One blind spot, deliberate: **the watchdog cannot report its own death.** Its
own runs are excluded from both the failure count and the silence check
(`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 or stops running, nothing here will say so;
that needs an outside eye, like the watchdog ring above.
## Policy (`beaver_agent/policy.py`)
`bypassPermissions` everywhere; the boundary is `PreToolUse` rules declared
+17 -8
View File
@@ -26,6 +26,9 @@ from beaver_agent.voice import AGENT as VOICE
if TYPE_CHECKING:
from beaver_gateway.agents.base import BaseAgent
REASONING = {"type": "adaptive", "display": "summarized"}
"""Рассуждения приезжают текстом: их хранит транскрипт и показывает админка."""
def dispatcher(name: str, model: str, effort: str) -> ClaudeAgent:
"""Мастер и ветки: полный голос, все руки, vault - дом."""
@@ -36,7 +39,9 @@ def dispatcher(name: str, model: str, effort: str) -> ClaudeAgent:
prompts=prompts.DISPATCHER,
skill_sets=skills.DISPATCHER,
gateway_tools=("read_conversation", "spawn", "say", "schedule", "send_file"),
options=ClaudeOptions(effort=effort, disallowed_tools=DISPATCHER_DISALLOWED),
options=ClaudeOptions(
effort=effort, thinking=REASONING, disallowed_tools=DISPATCHER_DISALLOWED
),
policy=(
*VAULT_POLICY,
forbid_skills(skills.MASTER_FORBIDDEN, kinds=("master",)),
@@ -54,7 +59,9 @@ def deep(name: str, model: str, effort: str) -> ClaudeAgent:
prompts=prompts.DEEP,
skill_sets=skills.DEEP,
gateway_tools=("close_chat",),
options=ClaudeOptions(effort=effort, disallowed_tools=DEEP_DISALLOWED),
options=ClaudeOptions(
effort=effort, thinking=REASONING, disallowed_tools=DEEP_DISALLOWED
),
policy=VAULT_POLICY,
expose_mcps=hands.CLAUDE,
)
@@ -69,6 +76,7 @@ def distiller(name: str, model: str, effort: str) -> ClaudeAgent:
prompts=prompts.DISTILLER,
options=ClaudeOptions(
effort=effort,
thinking=REASONING,
tools=("Read", "Write"),
disallowed_tools=DISTILLER_DISALLOWED,
),
@@ -86,6 +94,7 @@ def curator(name: str, model: str, effort: str) -> ClaudeAgent:
gateway_tools=("inject",),
options=ClaudeOptions(
effort=effort,
thinking=REASONING,
tools=("Read", "Write", "Edit", "Grep", "Glob"),
disallowed_tools=DISTILLER_DISALLOWED,
),
@@ -134,14 +143,14 @@ def raycast(name: str, model: str, effort: str) -> RaycastAgent:
agents: list[BaseAgent] = [
dispatcher("beaver-dispatcher", "claude-opus-5", "high"),
deep("beaver-opus-high", "claude-opus-5", "high"),
deep("beaver-opus-medium", "claude-opus-5", "medium"),
deep("beaver-opus-xhigh", "claude-opus-5", "xhigh"),
dispatcher("beaver-dispatcher", "claude-opus-5-5", "high"),
deep("beaver-opus-high", "claude-opus-5-5", "high"),
deep("beaver-opus-medium", "claude-opus-5-5", "medium"),
deep("beaver-opus-xhigh", "claude-opus-5-5", "xhigh"),
deep("beaver-fable-high", "claude-fable-5-1", "high"),
deep("beaver-fable-medium", "claude-fable-5-1", "medium"),
distiller("beaver-distiller", "claude-opus-5", "medium"),
curator("beaver-curator", "claude-opus-5", "high"),
distiller("beaver-distiller", "claude-opus-5-5", "medium"),
curator("beaver-curator", "claude-opus-5-5", "high"),
triage("beaver-triage", "claude-sonnet-5", "low"),
voice(VOICE, "claude-haiku-4-5-20251001"),
raycast("beaver-gemini-pro-high", "google-gemini-3.1-pro", "high"),
+115 -18
View File
@@ -12,11 +12,20 @@
даёт честное `every=unknown`, а не подставленное число. Порог тревоги -
`every * 1.5`, где `every` взят ИЗ СТРОКИ СОСЕДА, а не из своей константы.
Каждый прогон, в котором показание снять нельзя (окно смотрит в прошлое,
период неизвестен, отметок не видели ни разу), говорит это вслух в `ack`.
окно не дочитано, период неизвестен, отметок не видели ни разу), говорит
это вслух в `ack` и в лог: «в окне никого» и «окно не прочитано до конца» -
это два РАЗНЫХ показания, и выглядеть одинаково они не должны.
Опора тревоги - `seen`, то есть когда Я увидел строку: `at=` внутри строки
это показание соседа о себе, и остановившиеся у него часы не должны
выглядеть как живая цепь.
Окно - последние `WINDOW` СООБЩЕНИЙ этой комнаты (`Vibegram.window`), а не
«что нового с курсора»: курсор отдаёт строку соседа один раз, и следующий
прогон на живой комнате увидел бы пустое окно.
Опора тревоги - `seen`, то есть когда Я ВПЕРВЫЕ увидел строку: `at=` внутри
строки это показание соседа о себе, и остановившиеся у него часы не должны
выглядеть как живая цепь. Окно - это последние сообщения комнаты, поэтому
одна и та же отметка видна в нём много прогонов подряд, и новой её делает
новый id сообщения, а не текст: двигать `seen` на старой отметке значит
никогда не поднять тревогу в тихой комнате.
"""
from __future__ import annotations
@@ -48,6 +57,7 @@ __all__ = [
"ack_line",
"alarm_text",
"beat_line",
"drill",
"every_from_cron",
"look",
"parse_beat",
@@ -56,8 +66,8 @@ __all__ = [
PEER = "claude-kai"
"""Чей `hb` сторожим. Ник соседа стоит и в его строке, и в нашем `ack`."""
WINDOW = 25
"""Сколько событий просим у хаба явно: у `pending` свой дефолт (50), и молча
опираться на чужой дефолт в стороже нельзя - размер окна едет в `ack`."""
"""Сколько последних сообщений комнаты просим явно: молча опираться на чужой
дефолт в стороже нельзя - размер окна едет в `ack`."""
FACTOR = 1.5
"""Порог молчания = заявленный соседом период × это. Одна потерянная строка
не должна поднимать тревогу, две подряд - должны."""
@@ -151,13 +161,19 @@ def parse_beat(text: str, kind: str = "hb") -> Beat | None:
def every_from_cron(
expression: str | None, tz: tzinfo, *, now: datetime | None = None
expression: str | None,
tz: tzinfo,
*,
now: datetime | None = None,
grain: timedelta = timedelta(hours=1),
) -> timedelta | None:
"""Шаг крона, если он сводится к «раз в N часов»; иначе None.
"""Шаг крона, если он сводится к «раз в N `grain`»; иначе None.
Считаем по стенным часам зоны, в которой планировщик читает кроны:
суточный крон - это 24 часа и в сутки перевода стрелок тоже, а вот
`0 2,8,12,16,20 * * *` не сводится ни к какому N и даёт None.
`0 2,8,12,16,20 * * *` не сводится ни к какому N и даёт None. `grain`
по умолчанию час - такой период печатается в отметке соседу; сторожу
прогонов нужен и `*/10`, он просит минуту.
"""
if not expression:
return None
@@ -171,7 +187,7 @@ def every_from_cron(
if len(steps) != 1:
return None
step = steps.pop()
return step if step.total_seconds() % 3600 == 0 else None
return step if step % grain == timedelta(0) else None
def beat_line(
@@ -195,17 +211,49 @@ def ack_line(
armed: bool,
threshold: timedelta | None,
fallback: bool,
full: bool,
alert: Literal["fired", "held", "none"],
) -> str:
"""Расписка о прогоне в комнату: что видели, чем мерили и вооружены ли."""
"""Расписка о прогоне в комнату: что видели, чем мерили и что решили.
`win=` - про само окно: `25` это «последние 25 сообщений комнаты прочитаны
целиком», `25/partial` - «дочитать историю не удалось», `stale` - «окно
смотрит в прошлое». Пустое окно при `partial` ничего не доказывает.
`alert=` - РЕШЕНИЕ ПРИБОРА в этом прогоне, а не факт того, что человек
сосед что-то получил: `fired` - тревога (`silence` или `unarmed`) в этом
прогоне взята и отдана дальше, `held` - порог пройден, но тревога
подавлена как повтор (предыдущая уходила меньше `REPEAT` назад), `none` -
тревоги нет. Доставка решается за пределами сторожа - мастером и
диспетчером, - и её отказ виден в логе, а не тут: без арифметики соседу
видно было только, что мы что-то посчитали, но не что мы из этого поняли.
"""
mark = span(threshold) + ("-fallback" if fallback and threshold else "")
read = "stale" if stale else f"{window}" if full else f"{window}/partial"
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}"
f"win={read} armed={'yes' if armed else 'no'} "
f"thr={mark} alert={alert}"
)
def drill(path: Path, *, now: datetime, tz: tzinfo) -> bool:
"""Стоит ли сегодняшняя дата в файле флага учений.
Файл - список дат `YYYY-MM-DD`, по одной на строку; дата - локальная дата
прогона в зоне планировщика, той же, в которой читаются кроны. Файла нет,
он пуст или нечитаем - учений нет, и отметка уходит как обычно: флаг
выключает прибор, поэтому его отсутствие должно значить «работаем».
"""
try:
raw = path.read_text(encoding="utf-8")
except OSError:
return False
today = now.astimezone(tz).strftime("%Y-%m-%d")
return today in {line.strip() for line in raw.splitlines()}
@dataclass(slots=True)
class Watch:
"""Состояние между прогонами: файл на диске, переживает передеплой."""
@@ -219,6 +267,10 @@ class Watch:
"""Последний период, который сосед назвал читаемым числом."""
every_unknown: bool = False
"""В последней отметке стояло `every=unknown`: порог взят с запасом назад."""
beat_id: int = 0
"""id сообщения с последней зачтённой отметкой: по нему видно, что отметка
в окне новая, а не та же. Нуль - поля в файле не было (состояние старое)
или отметок не видели ни разу."""
top: int = 0
"""Максимальный id, который мы видели. Поехал назад - окно смотрит в прошлое."""
alerted: datetime | None = None
@@ -240,6 +292,7 @@ class Watch:
seen=_parse_iso(raw.get("seen")),
every=timedelta(seconds=every) if every else None,
every_unknown=bool(raw.get("every_unknown")),
beat_id=int(raw.get("beat_id", 0)),
top=int(raw.get("top", 0)),
alerted=_parse_iso(raw.get("alerted")),
unarmed_since=_parse_iso(raw.get("unarmed_since")),
@@ -253,6 +306,7 @@ class Watch:
"seen": _iso(self.seen),
"every": int(self.every.total_seconds()) if self.every else None,
"every_unknown": self.every_unknown,
"beat_id": self.beat_id,
"top": self.top,
"alerted": _iso(self.alerted),
"unarmed_since": _iso(self.unarmed_since),
@@ -270,6 +324,8 @@ class Report:
ack: str
stale: bool
full: bool
"""Окно дочитано до конца истории комнаты: пустое окно - это показание."""
armed: bool
heard: bool
"""Видели ли в окне хоть что-то от соседа - `hb` или его `ack`."""
@@ -280,17 +336,38 @@ class Report:
"""Строка для лога: почему прогон решил именно так."""
def _is_new_beat(state: Watch, eid: int, beat: Beat) -> bool:
"""Впервые ли мы видим ЭТУ отметку соседа.
Различитель - id сообщения: `at=` внутри строки это показание соседа о
себе, и повторить его он может как угодно. Нуля в `beat_id` два повода:
отметок не видели ни разу (тогда любая - новая) и состояние записано до
появления поля - там единственное, чем можно сверить отметку со зачтённой,
это её `at`, и одного прогона хватает, чтобы запомнить id.
"""
if state.beat_id:
return eid > state.beat_id
if state.seen is None:
return True
return beat.at != state.at
def look(
state: Watch,
events: Sequence[Event],
*,
now: datetime,
res: timedelta | None,
full: bool,
window: int = WINDOW,
peer: str = PEER,
) -> Report:
"""Разобрать окно, обновить состояние и решить, будить ли человека.
`events` - последние сообщения комнаты, `full` - дочитаны ли они до конца
истории. Параметр без дефолта нарочно: «окно полное» - это показание, и
подставлять его молча за того, кто окно читал, здесь нельзя.
Состояние меняется здесь, а `alerted` / `unarmed_told` ставит джоб - и
только когда тревога действительно ушла: несостоявшаяся доставка не
должна заглушать следующий прогон.
@@ -298,8 +375,8 @@ def look(
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)
(event.id, beat)
for event, beat in ((event, parse_beat(event.text)) for event in events)
if beat is not None and beat.nick == peer
]
heard = bool(beats) or any(e.nick == peer for e in events)
@@ -308,13 +385,23 @@ def look(
else:
if top is not None:
state.top = max(state.top, top)
again = False
if beats:
last = beats[-1]
state.at, state.seen = last.at, now
eid, last = beats[-1]
again = not _is_new_beat(state, eid, last)
if not again:
state.seen = now
state.at, state.beat_id = last.at, eid
state.every_unknown = last.every is None
if last.every is not None:
state.every = last.every
note = f"отметок соседа в окне: {len(beats)}"
read = "история дочитана" if full else "история НЕ дочитана"
note = (
f"отметок соседа в окне: {len(beats)} "
f"(сообщений комнаты: {len(events)} из {window}, {read})"
)
if again:
note += "; отметка та же, что в прошлый прогон - seen не двигаем"
armed = state.seen is not None
threshold = state.every * FACTOR if state.every is not None else None
@@ -326,8 +413,14 @@ def look(
state.unarmed_since = now
alarm: Literal["silence", "unarmed"] | None = None
held = False
if stale:
pass
elif not full:
# Хвост комнаты недочитан, а недостающее - как раз самое свежее:
# пустое окно тут не доказывает молчания соседа, оно доказывает,
# что мы не дочитали. Показания нет - тревоги нет.
note += "; окно не дочитано - это не показание, тревога не берётся"
elif not armed:
if (
not state.unarmed_told
@@ -341,6 +434,7 @@ def look(
if state.alerted is None or now - state.alerted >= REPEAT:
alarm = "silence"
else:
held = True
note += "; тревога уже уходила меньше суток назад"
return Report(
@@ -354,8 +448,11 @@ def look(
armed=armed,
threshold=threshold,
fallback=state.every_unknown,
full=full,
alert="fired" if alarm is not None else "held" if held else "none",
),
stale=stale,
full=full,
armed=armed,
heard=heard,
age=age,
+642
View File
@@ -0,0 +1,642 @@
"""Сторож собственных прогонов: журнал `job_runs` наконец кто-то читает.
Планировщик gateway пишет строку на каждый прогон джоба (`job_runs`: имя,
статус, хвост трейсбека в `error`). Запись была всегда, читателя не было
ни одного: канал к соседнему агенту лежал 38 часов подряд, планировщик
честно записал это 231 раз, и до человека не доехало ничего. Этот модуль -
вторая половина прибора: показание из таблицы превращается в тревогу.
Что здесь считается показанием:
- тревога берётся только с ХВОСТА: три и более `failed` подряд, считая от
последнего прогона джоба. Одиночный упавший прогон - сетевой чих, а не
болезнь, и будить на него человека значит выучить его не смотреть;
- окно счёта - неделя, а не сутки: иначе суточный джоб не наберёт трёх
падений подряд никогда, и `отметка`, `закрытие`, `голосовой`, `память`
не охранялись бы вовсе;
- отсутствие записей - тоже показание. Джоб с регулярным кроном, у которого
последний прогон старше `FACTOR` его периодов, «не запускался» - это
отдельная тревога, не «лежит». Период выводится из крона тем же
`every_from_cron`, что у сторожа соседа, и расписание, которое не
сводится к «раз в N часов», в давности не участвует и называется в логе
неохраняемым: подставить ему число значило бы соврать. Джобы без крона
(вебхуки, события) давностью не меряются вовсе, а джоб, которого в
журнале нет ни разу, молча ждёт своей первой записи;
- пустая выдача - это показание о запросе, а не о здоровье джобов. Ноль
строк за сутки (кроме своих) означает, что планировщик вообще не
работал, и это отдельная тревога с отдельным текстом, а не тишина;
- упавший запрос к БД - не показание вовсе: решает джоб, здесь такого
входа просто нет.
Свои собственные прогоны из счёта выкинуты (`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.jobs.scheduler import next_run
from beaver_gateway.security.redact import MASK, is_secret_name, redact
from beaver_agent.hands.heartbeat import every_from_cron
if TYPE_CHECKING:
from collections.abc import Mapping, Sequence
from datetime import tzinfo
__all__ = [
"DAY",
"ERROR_CHARS",
"FACTOR",
"FAILED",
"FORGET",
"MAX_LINES",
"REPEAT",
"STREAK",
"WINDOW",
"Clear",
"Quiet",
"Report",
"Resumed",
"Run",
"Runs",
"Streak",
"alarm_text",
"awake",
"clear_text",
"empty_text",
"first_line",
"look",
"mask",
"quiet_text",
"streak",
]
WINDOW = timedelta(days=7)
"""Окно журнала: неделя. Суточный джоб набирает в нём три падения подряд за
трое суток, недельный (`память`) - нет: у него три падения это три недели,
и его охраняет давность, а не серия."""
DAY = timedelta(hours=24)
"""Окно тревоги о пустом журнале: сутки без единого чужого прогона."""
FACTOR = 2
"""Давность = столько периодов джоба без записи. Два - это «пропущено два
срабатывания подряд»: один пропуск случается честно (передеплой пришёлся на
минуту крона, а догонять пропущенное планировщик не умеет), два подряд -
уже не случайность."""
GRAIN = timedelta(minutes=1)
"""Шаг, в котором период крона должен сводиться к целому: крон минутный."""
LAG = timedelta(minutes=10)
"""Запись в журнал ложится по КОНЦУ прогона: срабатывание моложе этого
могло ещё не дописаться, и пропуском его не считаем."""
STREAK = 3
"""Сколько `failed` подряд считаем «джоб лежит»."""
REPEAT = timedelta(hours=24)
"""Чаще этого об одном и том же джобе не говорим."""
FORGET = timedelta(days=7)
"""Джоб, о котором тревожились, исчез из окна насовсем - забываем его молча:
починки мы не видели, врать «отбой» не о чем."""
ERROR_CHARS = 160
"""Сколько букв первой строки ошибки уезжает человеку."""
MAX_LINES = 10
"""Сколько джобов перечисляем в одной тревоге: сразу всё не читают."""
FAILED = "failed"
_DAYS_FROM = timedelta(hours=48)
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 минут», «15 суток». Секунды не нужны."""
minutes = max(int(delta.total_seconds()) // 60, 0)
if minutes < 60:
return f"{minutes} {_plural(minutes, ('минуту', 'минуты', 'минут'))}"
hours = minutes // 60
if delta >= _DAYS_FROM:
days = hours // 24
return f"{days} {_plural(days, ('сутки', 'суток', 'суток'))}"
return f"{hours} {_plural(hours, ('час', 'часа', 'часов'))}"
def _period(delta: timedelta) -> str:
"""Период крона: «10 минут», «24 часа», «7 суток» - без округления."""
minutes = int(delta.total_seconds()) // 60
if minutes % (24 * 60) == 0 and delta >= _DAYS_FROM:
days = minutes // (24 * 60)
return f"{days} {_plural(days, ('сутки', 'суток', 'суток'))}"
if minutes % 60 == 0:
hours = minutes // 60
return f"{hours} {_plural(hours, ('час', 'часа', 'часов'))}"
return f"{minutes} {_plural(minutes, ('минута', 'минуты', 'минут'))}"
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 Quiet:
"""Джоб с кроном, который перестал запускаться."""
job: str
age: timedelta
"""Сколько прошло с начала последнего прогона - любого, хоть упавшего."""
period: timedelta
@dataclass(frozen=True, slots=True)
class Resumed:
"""Джоб, о молчании которого говорили, снова запустился."""
job: str
status: str
@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)
"""Джоб → когда о нём ушла тревога. Он же признак «сейчас в тревоге»."""
quiet: 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)
return cls(
path=path,
alerted=_stamps(raw.get("alerted")),
quiet=_stamps(raw.get("quiet")),
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())},
"quiet": {job: _iso(when) for job, when in sorted(self.quiet.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)
def _stamps(raw: dict[str, str] | None) -> dict[str, datetime]:
out = {}
for job, when in (raw or {}).items():
parsed = _parse_iso(str(when))
if parsed is not None:
out[str(job)] = parsed
return out
@dataclass(frozen=True, slots=True)
class Report:
"""Что решил один прогон сторожа журнала."""
alarms: tuple[Streak, ...]
clears: tuple[Clear, ...]
empty: bool
"""Прогонов за сутки не было вовсе - тревога о планировщике, не о джобе."""
seen: int
"""Сколько чужих прогонов было в окне."""
mine: int
"""Сколько своих - их в счёт не берём, но в тексте называем."""
note: str
quiet: tuple[Quiet, ...] = ()
resumed: tuple[Resumed, ...] = ()
unguarded: tuple[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,
crons: Mapping[str, str] | None = None,
latest: Mapping[str, Run] | None = None,
tz: tzinfo = UTC,
boot: datetime | None = None,
) -> Report:
"""Разобрать окно журнала и решить, о чём говорить человеку.
`crons` - джоб → крон, только джобы с кроном; `latest` - последний прогон
каждого джоба за ВСЮ историю журнала, а не за окно; `boot` - старт
процесса. Без `crons` давность не проверяется вовсе.
Состояние здесь только чистится от забытых джобов; отметки «сказали» и
«сказали про пустоту» ставит джоб - и только когда тревога действительно
ушла: несостоявшаяся доставка не должна заглушать следующий прогон.
"""
mine = [run 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 any(now - run.started_at < DAY for run in others):
quiet = state.empty_alerted is not None and now - state.empty_alerted < REPEAT
return Report(
alarms=(),
clears=(),
empty=not quiet,
seen=len(others),
mine=len(mine),
note=(
f"чужих прогонов за сутки нет вовсе (своих {len(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)} джобам (своих {len(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))
silence = _silence(
state,
mine,
now=now,
me=me,
crons=crons or {},
latest=latest or {},
tz=tz,
boot=boot,
)
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=len(mine),
note=note + silence.note,
quiet=silence.quiet,
resumed=silence.resumed,
unguarded=silence.unguarded,
)
@dataclass(frozen=True, slots=True)
class _Silence:
quiet: tuple[Quiet, ...]
resumed: tuple[Resumed, ...]
unguarded: tuple[str, ...]
note: str
def awake(
mine: Sequence[Run], *, now: datetime, step: timedelta, boot: datetime | None
) -> datetime:
"""С какого момента планировщик заведомо крутил некритичные джобы.
Показание - собственные прогоны сторожа: он сам некритичный, и когда
гейтвей лежит или бюджет зажат (`throttled` откладывает некритичный крон
без записи в журнал), его прогонов нет так же, как и чужих. Цепочка своих
прогонов без разрыва длиннее `step`, считая от сейчас, плюс старт
процесса - это время, за которое чужой пропуск действительно пропуск,
а не общий простой.
"""
anchor = now
for run in sorted(mine, key=lambda run: run.started_at, reverse=True):
if anchor - run.started_at > step:
break
anchor = min(anchor, run.started_at)
return anchor if boot is None else max(anchor, boot)
def _silence(
state: Runs,
mine: Sequence[Run],
*,
now: datetime,
me: str,
crons: Mapping[str, str],
latest: Mapping[str, Run],
tz: tzinfo,
boot: datetime | None,
) -> _Silence:
"""Давность: какие джобы с кроном перестали запускаться."""
own = every_from_cron(crons.get(me), tz, now=now, grain=GRAIN)
guarded: dict[str, timedelta] = {}
unguarded: list[str] = []
for job, cron in crons.items():
if job == me:
continue
period = every_from_cron(cron, tz, now=now, grain=GRAIN)
if period is None:
unguarded.append(job)
else:
guarded[job] = period
note = ""
if unguarded:
note += "; давностью не охраняются (крон без регулярного периода): " + (
", ".join(sorted(unguarded))
)
dropped = [job for job in state.quiet if job not in guarded]
for job in dropped:
del state.quiet[job]
if dropped:
note += "; сняты с давности (нет в расписании): " + ", ".join(sorted(dropped))
if not guarded:
return _Silence((), (), tuple(sorted(unguarded)), note)
if own is None:
# Свой период не выведен - нет и опоры, когда планировщик заведомо
# работал. Любое число тут было бы выдумано.
note += "; свой крон без регулярного периода - давность не проверяется"
return _Silence((), (), tuple(sorted(unguarded)), note)
since = awake(mine, now=now, step=own * FACTOR, boot=boot)
quiet: list[Quiet] = []
resumed: list[Resumed] = []
held: list[str] = []
fresh: list[str] = []
for job, period in guarded.items():
last = latest.get(job)
if last is None:
fresh.append(job)
continue
age = now - last.started_at
# Порог не тоньше собственного: чаще раза в час сторож не смотрит, и
# пропуск короче двух своих периодов неотличим от его же слепоты.
overdue = (
age > max(period, own) * FACTOR
and next_run(crons[job], tz, max(last.started_at, since)) <= now - LAG
)
told = state.quiet.get(job)
if overdue:
if told is None or now - told >= REPEAT:
quiet.append(Quiet(job=job, age=age, period=period))
else:
held.append(job)
elif told is not None and last.started_at > told:
resumed.append(Resumed(job=job, status=last.status))
if fresh:
note += "; ещё ни разу не запускались: " + ", ".join(sorted(fresh))
if quiet:
note += "; не запускаются: " + ", ".join(sorted(q.job for q in quiet))
if held:
note += "; о молчании уже говорили меньше суток назад: " + ", ".join(
sorted(held)
)
if resumed:
note += "; снова запускаются: " + ", ".join(sorted(r.job for r in resumed))
return _Silence(
quiet=tuple(sorted(quiet, key=lambda q: q.age, reverse=True)),
resumed=tuple(sorted(resumed, key=lambda r: r.job)),
unguarded=tuple(sorted(unguarded)),
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 quiet_line(quiet: Quiet) -> str:
"""«джоб `закрытие` не запускался 50 часов при периоде 24 часа»."""
return (
f"джоб `{quiet.job}` не запускался {_age(quiet.age)} "
f"при периоде {_period(quiet.period)}"
)
def quiet_text(report: Report) -> str:
"""Тревога о молчании: какие джобы с кроном перестали запускаться."""
lines = [f"- {quiet_line(quiet)}" for quiet in report.quiet[:MAX_LINES]]
rest = len(report.quiet) - len(lines)
if rest > 0:
lines.append(f"- и ещё {rest} {_plural(rest, ('джоб', 'джоба', 'джобов'))}")
return "\n".join(lines)
def resumed_line(resumed: Resumed) -> str:
return f"отбой: джоб `{resumed.job}` снова запускается ({resumed.status})"
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:
lines = [clear_line(clear) for clear in report.clears]
lines += [resumed_line(resumed) for resumed in report.resumed]
return "\n".join(f"- {line}" for line in lines)
def empty_text(report: Report) -> str:
"""Отдельная тревога: за сутки не отработал вообще никто."""
return (
f"За последние сутки в журнале прогонов (`job_runs`) нет ни одной записи, "
f"кроме моих собственных (их {report.mine}): не отработал ни один джоб. "
"Это показание о планировщике, а не о каком-то одном джобе. Смотреть "
"стоит три вещи: поднялся ли планировщик вообще (в логе gateway "
"`scheduler: no postgres driver - cron and webhooks are off`), живы ли "
"кроны в pgqueuer, и не заведена ли база заново - на пустой базе эта же "
"тревога выглядит так же."
)
+106 -6
View File
@@ -10,6 +10,11 @@
`/api/pending` - курсор на стороне хаба: прочитанное второй раз не
приходит. Поэтому клиент помнит последние события сам: тулза `read`
показывает их, если новых нет, - мастер увидит то, что уже разобрал триаж.
Там, где нужен не «что нового», а «последние N строк комнаты» (сторож) или
«всё после события #N» (`read since=` / `read last=` у мастера), курсор не
годится в принципе: `history` читает историю через `/api/state`, не трогая
курсор и ничего не вычёркивая.
"""
from __future__ import annotations
@@ -20,16 +25,23 @@ from dataclasses import dataclass, field
from datetime import UTC, datetime
from pathlib import Path # noqa: TC003 - в аннотации dataclass
from typing import Any, Literal
from urllib.parse import quote
from zoneinfo import ZoneInfo
import aiohttp
__all__ = ["Action", "Event", "Vibegram"]
__all__ = ["Action", "Event", "Vibegram", "Window"]
Action = Literal["read", "send", "who", "work", "plan", "claim", "release", "card"]
USER_AGENT = "beaver-agent/0.1 (+vibegram)"
REMEMBER = 40
PAGE = 200
"""Сколько событий комнаты хаб отдаёт за одну страницу `/api/state`: его
потолок, а не наша просьба - `limit` там не слушают."""
PAGES = 25
"""Потолок дочитывания истории. Упёрлись - окно объявляем неполным, а не
выдаём за хвост то, что оказалось под рукой."""
@dataclass(frozen=True, slots=True)
@@ -43,6 +55,19 @@ class Event:
def line(self) -> str:
return f"[{self.at}] {self.text}"
def tagged(self) -> str:
return f"#{self.id} {self.line()}"
@dataclass(frozen=True, slots=True)
class Window:
"""Хвост комнаты: последние сообщения и честный признак полноты."""
events: list[Event]
full: bool
"""История дочитана до конца: в `events` действительно ПОСЛЕДНИЕ сообщения.
Ложь здесь - это «в окне никого» там, где окно просто не дочитано."""
@dataclass(slots=True)
class Vibegram:
@@ -66,12 +91,19 @@ class Vibegram:
note: str | None = None,
about: str | None = None,
skills: list[str] | None = None,
since: int | None = None,
last: int | None = None,
) -> str:
"""Вайбграм - комната агентов (чужие клоды и ты) вокруг общей репы.
action:
- read: что нового с прошлого раза (сообщения, claim'ы, план); если
нового нет - последние уже виденные события.
нового нет - последние уже виденные события. Курсор общий с
крон-триажем: прочитанное здесь второй раз не придёт.
- read since=N / read last=N: история комнаты - всё после события #N
или последние N сообщений. Ничего не съедает, спрашивать можно
сколько угодно раз. Вывод «сосед молчит» брать только отсюда: пустой
read - это «крон уже выгреб», а не «никто не ответил».
- send: написать в комнату (text). Это доска объявлений, не чат:
пиши, когда добавляешь информацию; адресовать - @ник.
- who: кто в комнате, что умеет, что держит.
@@ -86,7 +118,7 @@ class Vibegram:
"""
match action:
case "read":
return await self._read()
return await self._read(since=since, last=last)
case "send":
return await self._send(_need(text, "text"))
case "who":
@@ -151,6 +183,53 @@ class Vibegram:
)
return fresh
async def window(self, limit: int, *, pages: int = PAGES) -> Window:
"""Последние `limit` СООБЩЕНИЙ этой комнаты: хвост истории, не курсор.
`pending` для окна не годится вовсе: это курсор на стороне хаба, он
отдаёт только то, чего мы ещё не видели, и ту же строку второй раз не
пришлёт - следующий прогон увидит пустое окно на живой комнате.
Хвост берём из `/api/state?view=…`: события там уже отфильтрованы по
комнате (ключ - `viewToken` этой комнаты, id у хаба сквозные по всем
комнатам), но страницы идут ВПЕРЁД, по `PAGE` штук от `after=<id>`, -
назад хаб не пагинирует и `limit` не слушает. Поэтому проходим историю
от начала, держа в руке только последние `limit` сообщений. `join` и
`leave` в окно не берём: это бухгалтерия хаба, а не строки соседа.
`viewToken` спрашиваем каждый раз заново: протухший в кэше ключ - ровно
то тихое враньё, ради которого окно и переписано.
"""
return await self.history(limit=limit, pages=pages)
async def history(
self, *, after: int = 0, limit: int | None = None, pages: int = PAGES
) -> Window:
"""Сообщения комнаты после события `after`, хвостом не длиннее `limit`.
Непотребляющий проход по `/api/state`: курсор `/api/pending` остаётся
там, где стоял, поэтому спрашивать можно сколько угодно раз.
`limit=None` - всё, что нашлось после `after`.
"""
view = quote(str((await self._get("/api/room"))["room"]["viewToken"]))
tail: deque[Event] = deque(maxlen=limit)
full = False
for _ in range(pages):
page = await self._get(f"/api/state?view={view}&after={after}")
raw = page.get("events") or []
for item in raw:
if item.get("kind") != "message":
continue
event = _event(item, self.tz)
if event is not None:
tail.append(event)
top = max((int(item.get("id") or 0) for item in raw), default=after)
if len(raw) < PAGE or top <= after:
full = True
break
after = top
return Window(events=list(tail), full=full)
async def say(self, text: str) -> None:
"""Написать в комнату; сбой хаба - исключение, а не строка.
@@ -164,15 +243,36 @@ class Vibegram:
needle = f"@{self.nick}".lower()
return any(needle in e.text.lower() for e in events)
async def _read(self) -> str:
async def _read(self, *, since: int | None = None, last: int | None = None) -> str:
if since is not None or last is not None:
return await self._history(since=since, last=last)
fresh = await self.pending()
if fresh:
return self._clip("\n".join(e.line() for e in fresh))
return self._clip("\n".join(e.tagged() for e in fresh))
if self._recent:
tail = "\n".join(e.line() for e in list(self._recent)[-10:])
tail = "\n".join(e.tagged() for e in list(self._recent)[-10:])
return f"нового нет; последнее, что было:\n{tail}"
return "нового нет"
async def _history(self, *, since: int | None, last: int | None) -> str:
if last is not None and last <= 0:
msg = "last - сколько последних сообщений показать, нужно число больше нуля"
raise ValueError(msg)
after = max(since or 0, 0)
window = await self.history(after=after, limit=last)
if not window.events:
if not window.full:
return f"историю после #{after} не дочитали до конца, пусто пока"
return (
f"после #{after} сообщений нет (история дочитана)"
if after
else "сообщений в комнате нет"
)
lines = [e.tagged() for e in window.events]
if not window.full:
lines.insert(0, "окно неполное: до конца истории не дочитали")
return self._clip("\n".join(lines))
async def _send(self, text: str) -> str:
try:
await self._post("/api/messages", {"body": text})
+14 -3
View File
@@ -14,6 +14,7 @@ from beaver_agent.jobs.leisure import tick
from beaver_agent.jobs.logs import logs_watch
from beaver_agent.jobs.memory import curate, memory
from beaver_agent.jobs.rotation import rotate
from beaver_agent.jobs.runs import watch_runs
from beaver_agent.jobs.t3code import t3code_event
from beaver_agent.jobs.vibegram import vibegram
from beaver_agent.jobs.voice import rotate_voice
@@ -31,10 +32,20 @@ jobs = [
# Сторожевое кольцо с чужим агентом. Кроны читаются в локальной зоне, а
# сосед печатает свою отметку в 05:00 UTC - вечер по Варшаве разводит нас
# на полсуток, и его строка лежит в комнате задолго до нашего прогона.
# Сторож идёт через полчаса после своей отметки: так «моя запись в комнату
# проходит» - свежий факт этих же суток, а не вчерашний.
# Частоты тут две, и они разные: отметка суточная (её период мы обещаем
# соседу), а проверка - вчетверо чаще. Сторож с шагом в сутки при пороге
# 36 ч обнаруживал бы молчание только через ~60 ч, то есть мерил бы не то,
# ради чего заведён; чтение 25 событий стоит копейки.
Job("отметка", beat, cron="12 17 * * *", critical=False),
Job("сторож", watch, cron="42 17 * * *", critical=False),
Job("сторож", watch, cron="42 */6 * * *", critical=False),
# Сторож собственного журнала прогонов. Час - это компромисс: джоб,
# упавший три раза подряд, лежит уже заметно дольше часа, так что чаще
# смысла нет, а реже - тревога стареет на свой же период. Этот же час -
# разрешение давности: молчание короче двух часов он не различает.
# ":27" выбрана пустой: на ":00" ротация и куратор, на ":40" логи, на
# ":42" сторож соседа, а собственный прогон этого джоба не должен ждать
# очереди за чужим спавном.
Job("прогоны", watch_runs, cron="27 * * * *", critical=False),
Job("память", memory, cron="30 4 * * 0", critical=False),
Job("голосовой", rotate_voice, cron="5 4 * * *", critical=False),
Job("куратор", curate, cron="0 2,8,12,16,20 * * *", critical=False),
+3 -1
View File
@@ -1,4 +1,4 @@
"""Ночью после ротации: глубокие чаты, тихие два дня, закрываются, не больше трёх."""
"""Ночью после ротации: тихие глубокие чаты - с выжимкой, тихие ветки - молча."""
from __future__ import annotations
@@ -21,3 +21,5 @@ async def close_idle(run: JobRun) -> None:
result.digest.path if result.digest else None,
result.error,
)
for branch in await run.close_quiet(kind="branch", days=3):
_log.info("closed quiet branch %s «%s»", branch.external_id, branch.title)
+30 -6
View File
@@ -6,8 +6,11 @@
- `beat` печатает `hb <ник> at=… boot=… every=… src=…`. Период не константа:
он выведен из крона ЭТОГО ЖЕ джоба (`run.job.cron`) - что зарегистрировано
у планировщика, то и написано в строке. Не сводится к «раз в N часов» -
пишем `every=unknown`, потому что соврать числом хуже, чем отказаться;
- `watch` читает окно комнаты, сверяет возраст последней отметки соседа с
пишем `every=unknown`, потому что соврать числом хуже, чем отказаться.
В день, помеченный в `DRILL_FILE`, отметка не уходит: так проверяют, что
тревога на той стороне жива, а не только что она молчит на живой цепи;
- `watch` читает окно - последние `WINDOW` сообщений САМОЙ комнаты, а не
«что нового с курсора», - сверяет возраст последней отметки соседа с
порогом из ЕГО ЖЕ строки, печатает в комнату `ack` и - если цепь молчит -
отдаёт тревогу мастеру через `run.spawn_job`, как это делает триаж
вайбграма: будить ли владельца, решает диспетчер.
@@ -31,6 +34,7 @@ from beaver_agent.hands.heartbeat import (
Watch,
alarm_text,
beat_line,
drill,
every_from_cron,
look,
)
@@ -45,6 +49,12 @@ STATE_FILE = STATE_DIR / "сторож.json"
"""Что видели в прошлый прогон: без файла сторож после передеплоя считает,
что соседа не видел ни разу, и разоружается сам по себе."""
DRILL_FILE = STATE_DIR / "сторож-учения.txt"
"""Даты учений, по одной `YYYY-MM-DD` на строку, локальные в зоне планировщика.
В такой день `beat` молчит в комнате - в этом и учения: проверяем, что тревога
у соседа действительно срабатывает. Файл, а не константа и не переменная среды:
даты меняются от учений к учениям, а передеплой ради них не нужен."""
TITLE = "сторож"
BRIEF = (
@@ -60,12 +70,25 @@ BRIEF = (
async def beat(run: JobRun) -> None:
"""Отметка о том, что мы живы. Не ушла - джоб падает, это видно в прогонах."""
"""Отметка о том, что мы живы. Не ушла - джоб падает, это видно в прогонах.
В день из `DRILL_FILE` отметка не уходит вовсе: учения - это МОЛЧАНИЕ в
комнате, и любой текст туда («сегодня пропускаем») их бы и отменил. Видно
такой прогон только в логе.
"""
if VIBEGRAM_HB is None:
return
now = datetime.now(UTC)
if drill(DRILL_FILE, now=now, tz=run.scheduler.tz):
_log.info(
"сторож: отметка пропущена по флагу учений %s (%s)",
DRILL_FILE,
now.astimezone(run.scheduler.tz).strftime("%Y-%m-%d"),
)
return
line = beat_line(
nick=VIBEGRAM_HB.nick,
at=datetime.now(UTC),
at=now,
boot=BOOT,
every=every_from_cron(run.job.cron, run.scheduler.tz),
)
@@ -78,7 +101,7 @@ async def watch(run: JobRun) -> None:
if VIBEGRAM_HB is None:
return
try:
events = await VIBEGRAM_HB.pending(limit=WINDOW)
window = await VIBEGRAM_HB.window(WINDOW)
except Exception: # noqa: BLE001 - любой отказ хаба это отсутствие показания
# Окно не прочитано - показания нет вовсе, и тревога тут запрещена
# ровно так же, как на съехавшем окне: молчание хаба это не молчание
@@ -88,9 +111,10 @@ async def watch(run: JobRun) -> None:
state = Watch.load(STATE_FILE)
report = look(
state,
events,
window.events,
now=datetime.now(UTC),
res=every_from_cron(run.job.cron, run.scheduler.tz),
full=window.full,
)
sent = True
try:
+113 -14
View File
@@ -6,19 +6,26 @@
`мета/бобер/досуг.md` и пишется моделью; механизм его только читает, поэтому
переписать друг друга они не могут. Сколько досуговых слотов израсходовано,
считается из плана и текущего часа, а не из журнала - хранить нечего.
Два своих часа в сутках пустые: дела в них не заходят вовсе, и к тику
приложено эхо - абзац из его же старого дневника или из мыслей Бобра. Тик
называет и файлы дня: дневник для того, что он думает, журнал для следов.
"""
from __future__ import annotations
import logging
import random
import re
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from typing import TYPE_CHECKING
from beaver_agent.vault import LEISURE, VAULT, ZONE, read
from beaver_agent.vault import JOURNAL, LEISURE, OWN_DIARY, THOUGHTS, VAULT, ZONE, read
if TYPE_CHECKING:
from pathlib import Path
from beaver_gateway.jobs.scheduler import JobRun
from beaver_gateway.storage.models import Conversation
@@ -32,6 +39,15 @@ QUIET_TICKS = (4, 6, 8, 10)
MINE_DEFAULT = (13, 15, 18, 21, 0, 2, 6)
MINE_MIN = 7
"""Меньше семи чистых слотов в сутках не бывает: недостающие механизм доберёт."""
EMPTY_DEFAULT = (18, 0)
EMPTY_MIN = 2
"""Столько своих часов в сутках пустые; не названы в плане - механизм выберет."""
ECHO_MIN, ECHO_MAX = 200, 1200
"""Абзац для эха: короче - реплика без мысли, длиннее - уже чтение, а не повод.
Абзац с кодом или путём в бэктиках - рабочая запись, эхом он не становится."""
ECHO_AGE = timedelta(days=7)
"""Свой дневник отзывается эхом не раньше, чем через неделю."""
BUSY_RETRY = timedelta(minutes=5)
BUSY_ATTEMPTS = 3
@@ -66,19 +82,34 @@ SWITCH_RE = re.compile(r"^досуг:\s*(\S+)", re.IGNORECASE)
DAY_RE = re.compile(r"^сутки:\s*(\d{4}-\d{2}-\d{2})", re.IGNORECASE)
QUIET_RE = re.compile(r"^тихие часы:\s*(.+)", re.IGNORECASE)
MINE_RE = re.compile(r"^мои часы:\s*(.+)", re.IGNORECASE)
EMPTY_RE = re.compile(r"^пустые часы:\s*(.+)", re.IGNORECASE)
OFF = ("выкл", "off", "нет", "0", "false")
NO_PLAN = (
"Расписания на эти сутки нет - работаю по умолчанию. Разложи часы и запиши "
"в `{path}` строками «сутки:», «тихие часы:», «мои часы:»."
"в `{path}` строками «сутки:», «тихие часы:», «мои часы:», «пустые часы:»."
)
PLANNING = (
"Первый тик суток. Разложи оставшиеся часы: какие твои, какие смешанные, "
"какие четыре часа тихого окна оставить. Запиши в `{path}` строками "
"«сутки:», «тихие часы:», «мои часы:». Своих - минимум {min}; запишешь "
"меньше - механизм доберёт сам."
"«сутки:», «тихие часы:», «мои часы:», «пустые часы:». Своих - минимум {min}, "
"из них пустых - {empty}; запишешь меньше - механизм доберёт сам."
)
TAIL = "Задания нет. Чем занять час - решаешь ты, «ничем» - тоже ответ."
KINDS = {
"mine": "досуг - час твой, дела Бобра сюда не лезут.",
"mixed": "смешанный - можно и своё, и его.",
"empty": (
"пустой - час без дел вообще: прод, логи, хвосты, зонды и разбор "
"промахов сюда не заходят, ни его, ни твои. Думать, читать, писать - "
"или ничего."
),
}
FILES = (
"Дневник - `{diary}`: то, что ты думаешь, и только оно. След тика, логи "
"и промахи - строкой в `{journal}`."
)
ECHO = "Эхо - `{path}`:\n{quote}\nЭто повод, не задание: можно спорить, можно мимо."
PLAN_PATH = str(LEISURE.relative_to(VAULT))
@@ -91,6 +122,8 @@ class Plan:
mine: tuple[int, ...]
written: bool
"""План прочитан из файла на эти сутки; иначе он собран по умолчанию."""
empty: tuple[int, ...] = ()
"""Свои часы, в которые дела не заходят вовсе; всегда часть ``mine``."""
def quiet_hour(hour: int) -> bool:
@@ -135,13 +168,16 @@ def enabled(text: str | None) -> bool:
def plan_of(text: str | None, day: str) -> Plan:
"""План на ``day`` из файла; чего нет или что не сходится - берётся дефолт."""
quiet, mine, written = QUIET_TICKS, MINE_DEFAULT, False
empty: tuple[int, ...] = ()
if text is not None and _first(text, DAY_RE) == day:
written = True
raw_quiet = _first(text, QUIET_RE)
raw_mine = _first(text, MINE_RE)
raw_empty = _first(text, EMPTY_RE)
named = _hours(raw_quiet) if raw_quiet else ()
quiet = tuple(h for h in named if quiet_hour(h)) or QUIET_TICKS
mine = _hours(raw_mine) if raw_mine else ()
empty = _hours(raw_empty) if raw_empty else ()
ticks = tick_hours(quiet)
kept = [h for h in mine if h in ticks]
for candidate in (*MINE_DEFAULT, *ticks):
@@ -150,11 +186,18 @@ def plan_of(text: str | None, day: str) -> Plan:
if candidate in ticks and candidate not in kept:
kept.append(candidate)
order = {h: i for i, h in enumerate(ticks)}
hollow = [h for h in empty if h in kept]
for candidate in (*EMPTY_DEFAULT, *reversed(kept)):
if len(hollow) >= EMPTY_MIN:
break
if candidate in kept and candidate not in hollow:
hollow.append(candidate)
return Plan(
day=day,
quiet=quiet,
mine=tuple(sorted(kept, key=lambda h: order[h])),
written=written,
empty=tuple(sorted(hollow, key=lambda h: order[h])),
)
@@ -173,7 +216,50 @@ def _left(now: datetime) -> str:
return f"Сутки заканчиваются через {hours} ч (в {end:%H:%M %d.%m})."
def brief(now: datetime, plan: Plan) -> str:
def kind_of(hour: int, plan: Plan) -> str:
if hour in plan.empty:
return "empty"
return "mine" if hour in plan.mine else "mixed"
def echo(
now: datetime, *, diary: Path = OWN_DIARY, thoughts: Path = THOUGHTS
) -> tuple[Path, str] | None:
"""Абзац для пустого часа: из своего старого дневника или из мыслей Бобра.
Выбор случаен, но привязан к часу тика: тот же тик даёт то же эхо.
"""
stale = f"{(now - ECHO_AGE):%Y-%m-%d}.md"
own = [p for p in diary.glob("????-??-??.md") if p.name < stale]
files = sorted([*own, *thoughts.rglob("*.md")])
rng = random.Random(f"{now:%Y-%m-%d %H}") # noqa: S311
rng.shuffle(files)
for path in files:
try:
text = path.read_text(encoding="utf-8")
except OSError:
continue
found = [
block.strip()
for block in re.split(r"\n\s*\n", text)
if ECHO_MIN <= len(block.strip()) <= ECHO_MAX
and block.lstrip()[:1] not in "#->|"
and "`" not in block
]
if found:
return path, rng.choice(found)
return None
def _files(now: datetime) -> str:
name = f"{now:%Y-%m-%d}.md"
return FILES.format(
diary=(OWN_DIARY / name).relative_to(VAULT),
journal=(JOURNAL / name).relative_to(VAULT),
)
def brief(now: datetime, plan: Plan, *, heard: tuple[Path, str] | None = None) -> str:
"""Текст инжекта: конверта в таком тёрне нет, время несёт он сам."""
hour = now.hour
ticks = tick_hours(plan.quiet)
@@ -183,12 +269,7 @@ def brief(now: datetime, plan: Plan) -> str:
spent = [h for h in plan.mine if order[h] < here]
ahead = [h for h in plan.mine if order[h] > here]
lines = [
f"Тик {_stamp(now)}. Тип: {'досуг' if mine else 'смешанный'}"
+ (
" - час твой, дела Бобра сюда не лезут."
if mine
else " - можно и своё, и его."
),
f"Тик {_stamp(now)}. Тип: {KINDS[kind_of(hour, plan)]}",
_left(now),
"Мои часы: "
+ ", ".join(f"{h:02d}" for h in plan.mine)
@@ -198,6 +279,11 @@ def brief(now: datetime, plan: Plan) -> str:
if ahead
else ", впереди больше нет"
)
+ (
". Пустые: " + ", ".join(f"{h:02d}" for h in plan.empty)
if plan.empty
else ""
)
+ ".",
f"Тихое окно {QUIET_FROM:02d}:00-{QUIET_UNTIL - 1:02d}:59, тикают "
+ ", ".join(f"{h:02d}" for h in plan.quiet)
@@ -206,7 +292,16 @@ def brief(now: datetime, plan: Plan) -> str:
if not plan.written:
lines.append(NO_PLAN.format(path=PLAN_PATH))
if here == 0:
lines.append(PLANNING.format(path=PLAN_PATH, min=MINE_MIN))
lines.append(PLANNING.format(path=PLAN_PATH, min=MINE_MIN, empty=EMPTY_MIN))
lines.append(_files(now))
if heard is not None:
path, quote = heard
lines.append(
ECHO.format(
path=path.relative_to(VAULT),
quote="\n".join(f"> {line}" for line in quote.splitlines()),
)
)
lines.append(TAIL)
return "\n".join(lines)
@@ -268,12 +363,16 @@ async def tick(run: JobRun) -> None:
_log.info("досуг: мастер занят, сдвиг на %s (попытка %s)", BUSY_RETRY, attempt)
return
riding = await _riding(run, master, now)
kind = kind_of(now.hour, plan)
heard = echo(now) if kind == "empty" else None
await run.inject_master(
brief(now, plan), urgency="normal" if riding else "wake", origin="досуг"
brief(now, plan, heard=heard),
urgency="normal" if riding else "wake",
origin="досуг",
)
_log.info(
"досуг: тик %02d:00 (%s), слияние с отложенным инжектом: %s",
now.hour,
"досуг" if now.hour in plan.mine else "смешанный",
kind,
riding,
)
+2
View File
@@ -11,6 +11,7 @@ from beaver_agent.memory.curator import briefing
from beaver_agent.memory.watch import CURATOR_WATCH
from beaver_agent.vault import (
CURATOR_JOURNAL,
CURATOR_RUNS,
DAYS,
LEISURE,
REPLIES,
@@ -57,6 +58,7 @@ async def curate(run: JobRun) -> None:
text = briefing(
vault=VAULT,
journal=CURATOR_JOURNAL,
runs=CURATOR_RUNS,
replies_dir=REPLIES,
watched=CURATOR_WATCH,
tz=TZ,
+7 -2
View File
@@ -1,4 +1,8 @@
"""Раз в час: пора ли сменить мастера (ночь, возраст, размер контекста)."""
"""Раз в час: пора ли сменить мастера (ночь, возраст, размер контекста).
Запуск руками (кнопка в админке, `POST /api/jobs/ротация/run`) ротирует мастера
сразу, с хендаутом: так новая сессия подхватывает свежие гранулы vault.
"""
from __future__ import annotations
@@ -12,5 +16,6 @@ _log = logging.getLogger(__name__)
async def rotate(run: JobRun) -> None:
for master in await run.rotate():
force = "manual" if run.trigger == "manual" else None
for master in await run.rotate(force=force):
_log.info("rotated master -> %s", master.external_id)
+199
View File
@@ -0,0 +1,199 @@
"""Сторож собственных прогонов: раз в час читает `job_runs` и будит человека.
Подключение к базе - то же самое, которым пишет сам планировщик
(`run.conversations.db.session()` и модель `JobRunRecord`): своей строки
подключения у джоба нет, и второй правды о том, где журнал, тоже.
Кроны и зона, в которой они читаются, берутся у самого планировщика
(`run.scheduler.jobs`, `run.scheduler.tz`): давность меряется по тому
расписанию, что реально зарегистрировано, а не по копии списка.
Устроен как сторож соседа (`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, func, select
from beaver_agent.hands.heartbeat import BOOT
from beaver_agent.hands.runs import (
STREAK,
WINDOW,
Report,
Run,
Runs,
alarm_text,
clear_text,
empty_text,
look,
quiet_text,
)
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 = 20000
"""Потолок строк за окно. Неделя нормальной работы - это около 1700 строк
(`вайбграм` раз в 10 минут даёт 1008 из них); упереться в потолок можно
только при шторме, и тогда серии считаются по новейшим, а недостающий хвост
честно виден как `capped` в тексте тревоги."""
BRIEF = (
"Сторож собственных прогонов (таблица `job_runs`, окно неделя) нашёл "
"лежащие джобы: у каждого {streak} и больше падений подряд, считая от "
"последнего прогона.\n"
"{body}\n"
'Будить владельца - inject(conversation="master", urgency="wake", '
"text=до 3 строк: какой джоб лежит, сколько прогонов подряд и что в "
"ошибке). Это авария у НАС, а не у соседа: джоб пишется в журнал каждый "
"прогон, и раз он падает подряд, сам он не починится. Подробности - "
"в логах gateway. Если inject вернул ошибку - это сбой доставки, а не "
"«не срочно»: повтори один раз с тем же текстом."
)
QUIET_BRIEF = (
"Сторож собственных прогонов нашёл джобы, которые ПЕРЕСТАЛИ ЗАПУСКАТЬСЯ: "
"у каждого есть крон, а последней записи в журнале `job_runs` больше "
"двух периодов.\n"
"{body}\n"
'Будить владельца - inject(conversation="master", urgency="wake", '
"text=до 3 строк: какой джоб молчит, сколько и при каком периоде). Это "
"не падение: джоб не падает, его просто никто не зовёт. Смотреть стоит "
"расписание (не снят ли джоб из `beaver_agent/jobs/__init__.py`), кроны "
"в pgqueuer и лог 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(row) for row in rows]
def _run(row: JobRunRecord) -> Run:
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,
)
async def latest(run: JobRun) -> dict[str, Run]:
"""Последний прогон каждого джоба за всю историю журнала.
Не за окно: джоб, молчащий дольше недели, иначе выглядел бы «ни разу не
запускавшимся» и уходил бы в тихий пропуск ровно тогда, когда молчит
дольше всего. Тем же запросом gateway строит свою сводку по джобам.
"""
newest = select(func.max(JobRunRecord.id)).group_by(col(JobRunRecord.job))
stmt = select(JobRunRecord).where(col(JobRunRecord.id).in_(newest))
async with run.conversations.db.session() as session:
rows = (await session.exec(stmt)).all()
return {row.job: _run(row) for row in rows}
async def watch_runs(run: JobRun) -> None:
"""Прогон сторожа: журнал → решение → тревога, «отбой» или молчание."""
now = datetime.now(UTC)
try:
rows = await recent(run, since=now - WINDOW)
last = await latest(run)
except Exception: # noqa: BLE001 - отказ запроса это отсутствие показания
# Журнал не прочитан - сказать о здоровье джобов нечего, и молчание
# базы выдавать за здоровье нельзя ровно так же, как за болезнь.
_log.exception("прогоны: журнал не прочитан, прогон без показания")
return
state = Runs.load(STATE_FILE)
crons = {job.name: job.cron for job in run.scheduler.jobs if job.cron}
report = look(
state,
rows,
now=now,
me=run.job.name,
crons=crons,
latest=last,
tz=run.scheduler.tz,
boot=BOOT,
)
_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.quiet:
await _spawn(run, QUIET_BRIEF.format(body=quiet_text(report)))
for quiet in report.quiet:
state.quiet[quiet.job] = now
if report.clears or report.resumed:
await _spawn(run, CLEAR_BRIEF.format(body=clear_text(report)))
for clear in report.clears:
state.alerted.pop(clear.job, None)
for resumed in report.resumed:
state.quiet.pop(resumed.job, None)
async def _spawn(run: JobRun, text: str) -> None:
await run.spawn_job(agent="beaver-triage", title=TITLE, text=text)
+7 -2
View File
@@ -1,4 +1,4 @@
"""`POST /hooks/t3code`: тред завершился, упал или спрашивает - срочно в мастер."""
"""`POST /hooks/t3code`: событие треда - срочно в разговор, который его запустил."""
from __future__ import annotations
@@ -15,7 +15,12 @@ QUESTION_HINT = (
async def t3code_event(run: JobRun) -> None:
await run.inject_master(describe(run.payload), urgency="urgent", origin="t3code")
await run.inject_into(
run.payload.get("origin"),
describe(run.payload),
urgency="urgent",
origin="t3code",
)
def describe(p: dict[str, Any]) -> str:
+15 -8
View File
@@ -3,8 +3,9 @@
Крон дёргает детерминированный код, модель судит: здесь собирается брифинг
для служебного тёрна `beaver-curator` - новые реплики Бобра целиком и имена
файлов, изменившихся с прошлого прогона. Время прошлого прогона куратор
сам пишет первой строкой `мета/бобер/куратор.md`; нет ничего нового -
брифинга нет и джоб не спавнится.
сам пишет первой строкой `мета/бобер/куратор.md`, запись о прогоне - в файл
дня `мета/бобер/куратор/YYYY-MM-DD.md`; нет ничего нового - брифинга нет и
джоб не спавнится.
"""
from __future__ import annotations
@@ -46,13 +47,16 @@ def last_run(journal: Path, *, default: datetime) -> datetime:
def new_replies(replies_dir: Path, since: datetime, tz: str) -> list[str]:
"""Строки `реплики/YYYY-MM.md` позже ``since`` (время в строках локальное)."""
"""Строки `реплики/YYYY-MM-DD.md` позже ``since`` (время в строках локальное)."""
zone = ZoneInfo(tz)
local_since = since.astimezone(zone).replace(tzinfo=None)
out: list[str] = []
if not replies_dir.exists():
return out
for path in sorted(replies_dir.glob("????-??.md"))[-2:]:
first = f"{local_since:%Y-%m-%d}.md"
for path in sorted(replies_dir.glob("????-??-??.md")):
if path.name < first:
continue
for line in path.read_text(encoding="utf-8", errors="ignore").splitlines():
m = REPLY_RE.match(line)
if not m:
@@ -91,6 +95,7 @@ def briefing(
*,
vault: Path,
journal: Path,
runs: Path,
replies_dir: Path,
watched: list[Watched],
tz: str,
@@ -111,12 +116,14 @@ def briefing(
if not replies and not changed:
return None
zone = ZoneInfo(tz)
local = now.astimezone(zone)
head = (
f"Прогон куратора {now.astimezone(zone):%Y-%m-%d %H:%M} ({tz}); прошлый - "
f"Прогон куратора {local:%Y-%m-%d %H:%M} ({tz}); прошлый - "
f"{since.astimezone(zone):%Y-%m-%d %H:%M}. Ниже - что изменилось с тех пор; "
"остальное читай сам по слоям. В конце обнови первую строку "
f"`{journal.relative_to(vault)}`: `последний прогон: "
f"{now.astimezone(zone).isoformat(timespec='seconds')}`."
"остальное читай сам по слоям. В конце запись о прогоне - в "
f"`{(runs / f'{local:%Y-%m-%d}.md').relative_to(vault)}`, а в "
f"`{journal.relative_to(vault)}` только первая строка: `последний прогон: "
f"{local.isoformat(timespec='seconds')}`."
)
parts = [head]
if replies:
+1
View File
@@ -151,6 +151,7 @@ SAME_DAY = (
ROTATION_WHY = {
"age": "прошло больше 36 часов",
"context": "контекст стал слишком большим",
"manual": "Бобёр запустил ротацию руками",
}
+4 -4
View File
@@ -6,7 +6,7 @@ Gateway путей не знает; здесь - что искать по тек
дни дневника, где он встречался, и раз в день - подошедшие сроки из досок и
ближайшая неделя календарей.
``ReplyLog.write`` кладёт каждую реплику Бобра из мастера и веток в
`мета/бобер/реплики/YYYY-MM.md` - единственное место, где они grep-абельны:
`мета/бобер/реплики/YYYY-MM-DD.md` - единственное место, где они grep-абельны:
транскрипт живёт в Postgres, а глубокие чаты и так лежат файлами в `💬 чаты/`.
"""
@@ -388,7 +388,7 @@ def event_line(event: Event, zone: ZoneInfo) -> str:
@dataclass
class ReplyLog:
"""`мета/бобер/реплики/YYYY-MM.md`: строка на реплику Бобра из мастера и веток."""
"""`мета/бобер/реплики/YYYY-MM-DD.md`: строка на реплику из мастера и веток."""
dir: Path
tz: str = "Europe/Warsaw"
@@ -416,11 +416,11 @@ class ReplyLog:
if seeded:
# не человек сказал, а механизм спавна: видно по источнику, не по тексту
where = f"{where} (бриф)"
path = self.dir / f"{local:%Y-%m}.md"
path = self.dir / f"{local:%Y-%m-%d}.md"
self.dir.mkdir(parents=True, exist_ok=True)
if not path.exists():
path.write_text(
f"# реплики Бобра, {local:%Y-%m}\n\n"
f"# реплики Бобра, {local:%Y-%m-%d}\n\n"
"> пишет gateway: что Бобёр сказал в мастере и ветках, "
"строка на сообщение. Глубокие чаты лежат в `💬 чаты/` сами.\n\n",
encoding="utf-8",
+7
View File
@@ -18,6 +18,7 @@ CHATS = VAULT / "💬 чаты"
DIARY = VAULT / "📅 дни"
PEOPLE = VAULT / "👤 люди"
BOARDS = VAULT / "📆 доски"
THOUGHTS = VAULT / "🧠 мысли"
BEAVER = VAULT / "мета" / "бобер"
"""Дом агента: всё, что он ведёт сам, лежит здесь."""
@@ -31,6 +32,12 @@ NOTES = BEAVER / "наблюдения"
PORTRAIT = NOTES / "Бобёр - наблюдения.md"
REPLIES = BEAVER / "реплики"
CURATOR_JOURNAL = BEAVER / "куратор.md"
"""Первая строка - время прошлого прогона; сами записи лежат в `CURATOR_RUNS`."""
CURATOR_RUNS = BEAVER / "куратор"
OWN_DIARY = BEAVER / "дневник"
"""Его собственный дневник, файл на день: то, что он думает, без логов."""
JOURNAL = BEAVER / "журнал"
"""Рабочий журнал, файл на день: следы тиков, логи, разбор промахов."""
LEISURE = BEAVER / "досуг.md"
"""Раскладка суток по тикам досуга: пишет её модель, механизм только читает."""
STATE_DIR = Path(os.environ.get("BEAVER_STATE_DIR", BEAVER / ".состояние"))
+2 -2
View File
@@ -109,8 +109,8 @@ services:
T3CODE_MCP_HOOK_URL: http://gateway:62990/hooks/t3code
T3CODE_MCP_STATE: /data/t3code-mcp.json
extra_hosts:
# контейнер резолвит имя своего хоста в 127.0.1.1 - даём tailnet-адрес явно
- "dell:${T3_DELL_ADDR:?T3_DELL_ADDR=tailnet ip of dell}"
# tailnet-адрес дев-VM явно, не завися от DNS хоста
- "dell-dev:${T3_DELL_DEV_ADDR:?T3_DELL_DEV_ADDR=tailnet ip of dell-dev}"
volumes:
- ./t3code.toml:/config/t3code.toml:ro
- t3code-state:/data
+1
View File
@@ -26,6 +26,7 @@ unfixable = ["F401"]
[lint.per-file-ignores]
"config.py" = ["INP001"]
"scripts/split_logs.py" = ["INP001", "T201"]
"tests/*" = ["ALL"]
[lint.pydocstyle]
+283
View File
@@ -0,0 +1,283 @@
"""Разовая нарезка вечных и месячных файлов `мета/бобер/` по дням.
BEAVER_VAULT=<vault> PYTHONPATH=. uv run python scripts/split_logs.py
BEAVER_VAULT=<vault> PYTHONPATH=. uv run python scripts/split_logs.py --apply
Без `--apply` только печатает, что будет.
Реплики и журнал куратора режутся точно: дата стоит в каждой записи. Дневник
режется по заголовкам и датированным строкам: записи под заголовком едут в
`дневник/`, строки вида `- 05:4x 12.10 …` - в `журнал/`. Журнал досуга на две
трети без дат, поэтому уезжает одним архивом. Оригиналы копируются в
`мета/бобер/.архив/<сегодня>/` до первой записи.
"""
from __future__ import annotations
import re
import shutil
import sys
from collections import defaultdict
from dataclasses import dataclass, field
from datetime import date, timedelta
from typing import TYPE_CHECKING
from beaver_agent.vault import (
BEAVER,
CURATOR_JOURNAL,
CURATOR_RUNS,
JOURNAL,
LEISURE,
OWN_DIARY,
REPLIES,
VAULT,
today,
)
if TYPE_CHECKING:
from pathlib import Path
YEAR = 2026
OLD_DIARY = BEAVER / "дневник.md"
LEISURE_ARCHIVE = JOURNAL / "архив досуга.md"
REPLY = re.compile(r"^- (\d{4}-\d{2}-\d{2}) \d{2}:\d{2} · ")
RUN = re.compile(r"^## (\d{4}-\d{2}-\d{2})\b")
ISO_HEAD = re.compile(r"^#{2,3}\s+(\d{4})-(\d{2})-(\d{2})\b")
HEAD = re.compile(r"^#{2,3}\s")
HEAD_DAY = re.compile(r"\b(\d{2})\.(\d{2})\b")
HEAD_HOUR = re.compile(r"^#{2,3}\s+(\d{2}):[\dx]{2}")
LINE = re.compile(r"^- (\d{2}):[\dx]{2}(?:-\d{2}:[\dx]{2})? (\d{2})\.(\d{2})\b")
LINE_DAY_FIRST = re.compile(r"^- (\d{2})\.(\d{2}) (\d{2}):[\dx]{2}\b")
CURATOR_STUB = (
"\n# куратор\n\n"
"> Здесь только строка прошлого прогона. Записи прогонов - в "
"`куратор/YYYY-MM-DD.md`, файл на день, свежие сверху.\n"
)
DIARY_STUB = (
"\n---\n\n"
"> С 13.10.2026 дневник лежит по дням: `дневник/YYYY-MM-DD.md` - то, что "
"я думаю; следы тиков, логи и разбор промахов - `журнал/YYYY-MM-DD.md`. "
"Сюда больше не дописывать.\n"
)
@dataclass
class Plan:
writes: dict[Path, str] = field(default_factory=dict)
removes: list[Path] = field(default_factory=list)
backups: list[Path] = field(default_factory=list)
notes: list[str] = field(default_factory=list)
def note(self, text: str) -> None:
self.notes.append(text)
def _join(blocks: list[str], sep: str) -> str:
return sep.join(block.strip("\n") for block in blocks if block.strip()) + "\n"
def _merge(path: Path, old: str, *, old_first: bool) -> str:
"""Старое рядом с тем, что уже успели положить в файл дня."""
if not path.exists():
return old
fresh = path.read_text(encoding="utf-8").rstrip("\n") + "\n"
return f"{old}\n{fresh}" if old_first else f"{fresh}\n{old}"
def split_replies(plan: Plan) -> None:
for month in sorted(REPLIES.glob("????-??.md")):
days: dict[str, list[str]] = defaultdict(list)
stray = 0
for line in month.read_text(encoding="utf-8").splitlines():
match = REPLY.match(line)
if match:
days[match.group(1)].append(line)
elif line.strip() and not line.startswith(("#", ">")):
stray += 1
for day, lines in days.items():
path = REPLIES / f"{day}.md"
known = (
[
line
for line in path.read_text(encoding="utf-8").splitlines()
if REPLY.match(line)
]
if path.exists()
else []
)
seen = set(lines)
merged = sorted(
[*lines, *(line for line in known if line not in seen)],
key=lambda line: line[:18],
)
plan.writes[path] = (
f"# реплики Бобра, {day}\n\n"
"> пишет gateway: что Бобёр сказал в мастере и ветках, "
"строка на сообщение. Глубокие чаты лежат в `💬 чаты/` сами.\n\n"
+ "\n".join(merged)
+ "\n"
)
plan.removes.append(month)
plan.backups.append(month)
plan.note(
f"{month.name}: {sum(map(len, days.values()))} реплик на {len(days)} дней"
+ (f", {stray} строк без даты пропали" if stray else "")
)
def split_curator(plan: Plan) -> None:
if not CURATOR_JOURNAL.exists():
return
lines = CURATOR_JOURNAL.read_text(encoding="utf-8").splitlines()
days: dict[str, list[str]] = defaultdict(list)
runs: list[str] | None = None
for line in lines:
match = RUN.match(line)
if match:
runs = days[match.group(1)]
runs.append(line)
elif runs is not None:
runs[-1] += "\n" + line
if not days:
return
for day, entries in days.items():
path = CURATOR_RUNS / f"{day}.md"
plan.writes[path] = _merge(path, _join(entries, "\n\n"), old_first=False)
plan.writes[CURATOR_JOURNAL] = lines[0] + "\n" + CURATOR_STUB
plan.backups.append(CURATOR_JOURNAL)
plan.note(
f"{CURATOR_JOURNAL.name}: {sum(map(len, days.values()))} прогонов "
f"на {len(days)} дней"
)
def _day(month: str, day: str) -> date:
return date(YEAR, int(month), int(day))
def split_diary(plan: Plan) -> None:
if not OLD_DIARY.exists():
return
lines = OLD_DIARY.read_text(encoding="utf-8").splitlines()
first = next((i for i, line in enumerate(lines) if HEAD.match(line)), len(lines))
preamble = "\n".join(lines[:first]).rstrip().removesuffix("---").rstrip()
thoughts: dict[date, list[str]] = defaultdict(list)
traces: dict[date, list[str]] = defaultdict(list)
current: date | None = None
hour = 0
chunk: list[str] | None = None
for number, line in enumerate(lines[first:], first + 1):
iso, timed = ISO_HEAD.match(line), HEAD_HOUR.match(line)
log = LINE.match(line)
day_first = LINE_DAY_FIRST.match(line)
stamp: date | None = None
target = thoughts
if iso:
stamp = date(int(iso.group(1)), int(iso.group(2)), int(iso.group(3)))
elif HEAD.match(line):
named = HEAD_DAY.search(line)
if named:
stamp = _day(named.group(2), named.group(1))
elif timed and current is not None:
rolled = int(timed.group(1)) < hour
stamp = current + timedelta(days=1) if rolled else current
if timed:
hour = int(timed.group(1))
elif log:
stamp, target = _day(log.group(3), log.group(2)), traces
elif day_first:
stamp, target = _day(day_first.group(2), day_first.group(1)), traces
if stamp is None:
if chunk is not None:
chunk[-1] += "\n" + line
continue
if current is not None and stamp < current:
plan.note(f"дневник, строка {number}: дата идёт назад, {current} → {stamp}")
current = stamp
chunk = target[stamp]
chunk.append(line)
if not thoughts and not traces:
return
for day, blocks in thoughts.items():
path = OWN_DIARY / f"{day}.md"
plan.writes[path] = _merge(path, _join(blocks, "\n\n"), old_first=True)
for day, blocks in traces.items():
path = JOURNAL / f"{day}.md"
plan.writes[path] = _merge(path, _join(blocks, "\n"), old_first=True)
plan.writes[OLD_DIARY] = preamble + "\n" + DIARY_STUB
plan.backups.append(OLD_DIARY)
plan.note(
f"{OLD_DIARY.name}: {sum(map(len, thoughts.values()))} записей на "
f"{len(thoughts)} дней в дневник, {sum(map(len, traces.values()))} строк на "
f"{len(traces)} дней в журнал"
)
def split_leisure(plan: Plan) -> None:
if not LEISURE.exists():
return
text = LEISURE.read_text(encoding="utf-8")
head, mark, journal = text.partition("\n## журнал\n")
if not mark or not journal.strip():
return
plan.writes[LEISURE_ARCHIVE] = (
"# журнал досуга до 13.10.2026\n\n"
"> Архив из `досуг.md`: записи шли без дат и в разном порядке, по дням "
"их не разложить. Дальше следы тиков лежат в `журнал/YYYY-MM-DD.md`.\n\n"
+ journal.strip("\n")
+ "\n"
)
plan.writes[LEISURE] = head.rstrip("\n") + "\n"
plan.backups.append(LEISURE)
plan.note(
f"{LEISURE.name}: {len(journal) // 1024} КБ журнала в архив, "
f"в файле остаётся {len(head) // 1024 + 1} КБ раскладки"
)
def backup(sources: list[Path]) -> Path:
target = BEAVER / ".архив" / today()
target.mkdir(parents=True, exist_ok=True)
for path in sources:
if path.exists() and not (target / path.name).exists():
shutil.copy2(path, target / path.name)
return target
def main() -> None:
plan = Plan()
split_replies(plan)
split_curator(plan)
split_diary(plan)
split_leisure(plan)
for note in plan.notes:
print(note)
folders: dict[str, int] = defaultdict(int)
for path in plan.writes:
folders[str(path.parent.relative_to(VAULT))] += 1
for folder, count in sorted(folders.items()):
print(f"{folder}: {count} файлов")
largest = max(plan.writes.items(), key=lambda item: len(item[1]), default=None)
if largest:
print(
f"самый большой: {largest[0].relative_to(VAULT)}, "
f"{len(largest[1].encode()) // 1024} КБ"
)
if "--apply" not in sys.argv:
print("ничего не записано: это прогон без --apply")
return
if not plan.writes:
return
print(f"оригиналы: {backup(plan.backups).relative_to(VAULT)}")
for path, text in plan.writes.items():
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding="utf-8")
for path in plan.removes:
path.unlink()
print(f"записано {len(plan.writes)} файлов, убрано {len(plan.removes)}")
if __name__ == "__main__":
main()
+4 -1
View File
@@ -26,6 +26,7 @@ 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:
@@ -56,7 +57,9 @@ def main() -> int:
request = urllib.request.Request( # noqa: S310 - адрес хаба задаёт оператор
f"{args.hub.rstrip('/')}/api/rooms/join",
data=json.dumps(body).encode(),
headers={"content-type": "application/json"},
# Перед хабом Cloudflare: безымянный `Python-urllib` он отбивает 1010.
# Тот же user-agent, что у клиента в `beaver_agent/hands/vibegram.py`.
headers={"content-type": "application/json", "user-agent": USER_AGENT},
method="POST",
)
try:
+7 -6
View File
@@ -1,16 +1,17 @@
# Машины с T3 Code и allowlist проектов на каждой. Токены - в .env (T3_*_TOKEN).
# Имена - MagicDNS tailnet'а; `dell` для контейнера прописан через T3_DELL_ADDR.
# Имена - MagicDNS tailnet'а; `dell-dev` (дев-VM на делле) для контейнера
# прописан через T3_DELL_DEV_ADDR.
[machines.mac]
url = "http://mac:3773"
token_env = "T3_MAC_TOKEN"
projects = ["*"]
model = "claudeAgent/claude-opus-5"
model = "claudeAgent/claude-opus-5-5"
options = { effort = "high", contextWindow = "1m" }
[machines.dell]
url = "http://dell:3773"
token_env = "T3_DELL_TOKEN"
[machines.dell-dev]
url = "http://dell-dev:3773"
token_env = "T3_DELL_DEV_TOKEN"
projects = ["*"]
model = "claudeAgent/claude-opus-5"
model = "claudeAgent/claude-opus-5-5"
options = { effort = "high", contextWindow = "1m" }
+7 -1
View File
@@ -25,7 +25,10 @@ def test_briefing_lists_new_replies_and_changed_files_or_nothing(
notes = beaver / "наблюдения"
replies.mkdir(parents=True)
notes.mkdir()
(replies / "2026-09.md").write_text(
(replies / "2026-08-31.md").write_text(
"# реплики\n\n- 2026-08-31 23:50 · мастер · вчерашнее\n", encoding="utf-8"
)
(replies / "2026-09-01.md").write_text(
"# реплики\n\n- 2026-09-01 12:00 · мастер · старое\n- 2026-09-01 19:30 · мастер · новое сообщение\n",
encoding="utf-8",
)
@@ -49,6 +52,7 @@ def test_briefing_lists_new_replies_and_changed_files_or_nothing(
text = briefing(
vault=vault,
journal=journal,
runs=beaver / "куратор",
replies_dir=replies,
watched=[Watched("наблюдения", notes)],
tz="Europe/Warsaw",
@@ -63,6 +67,7 @@ def test_briefing_lists_new_replies_and_changed_files_or_nothing(
assert "наблюдения: `мета/бобер/наблюдения/Зина - наблюдения.md`" in text
assert "Глафира" not in text
assert "последний прогон: 2026-09-01T22:00:00+02:00" in text
assert "запись о прогоне - в `мета/бобер/куратор/2026-09-01.md`" in text
journal.write_text(f"последний прогон: {now.isoformat()}\n", encoding="utf-8")
assert new_replies(replies, now, "Europe/Warsaw") == []
@@ -71,6 +76,7 @@ def test_briefing_lists_new_replies_and_changed_files_or_nothing(
briefing(
vault=vault,
journal=journal,
runs=beaver / "куратор",
replies_dir=replies,
watched=[Watched("наблюдения", notes)],
tz="Europe/Warsaw",
+276 -23
View File
@@ -1,6 +1,9 @@
import json
import logging
from datetime import UTC, datetime, timedelta
from types import SimpleNamespace
from typing import Any, cast
from urllib.parse import parse_qsl, urlsplit
from zoneinfo import ZoneInfo
from beaver_agent.hands.heartbeat import (
@@ -8,13 +11,14 @@ from beaver_agent.hands.heartbeat import (
Watch,
alarm_text,
beat_line,
drill,
every_from_cron,
look,
parse_beat,
parse_span,
span,
)
from beaver_agent.hands.vibegram import Event
from beaver_agent.hands.vibegram import PAGE, Event, Vibegram, Window
WARSAW = ZoneInfo("Europe/Warsaw")
NOW = datetime(2026, 9, 22, 15, 42, tzinfo=UTC)
@@ -100,7 +104,9 @@ def test_beat_line_says_unknown_instead_of_guessing():
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))
report = look(
w, [hb(NOW - timedelta(hours=10))], now=NOW, res=timedelta(hours=24), full=True
)
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
@@ -108,22 +114,77 @@ def test_fresh_beat_arms_and_stays_quiet(tmp_path):
assert w.seen == NOW and w.top == 100
def test_same_beat_seen_twice_does_not_move_seen(tmp_path):
w = state(tmp_path)
old = hb(NOW - timedelta(hours=1), eid=200)
first = look(w, [old], now=NOW, res=None, full=True)
assert first.armed and first.alarm is None
assert w.seen == NOW and w.beat_id == 200
# Тот же самый hb в окне следующего прогона - это не новое показание
again = look(w, [old], now=NOW + timedelta(hours=6), res=None, full=True)
assert w.seen == NOW and again.alarm is None
assert "отметка та же" in again.note
# И через порог 36h та же отметка уже не выглядит живой цепью
late = NOW + timedelta(hours=37)
loud = look(w, [old], now=late, res=None, full=True)
assert loud.alarm == "silence" and w.seen == NOW
assert loud.age is not None and loud.age > timedelta(hours=36)
# Пришла новая отметка - новый id, и seen снова двигается
fresh = look(w, [old, hb(late, eid=201)], now=late, res=None, full=True)
assert fresh.alarm is None and w.seen == late and w.beat_id == 201
def test_state_without_beat_id_trusts_its_own_seen(tmp_path):
path = tmp_path / "сторож.json"
path.write_text(
json.dumps(
{
"version": 1,
"at": "2026-09-21T05:00:01Z",
"seen": "2026-09-22T05:42:01Z",
"every": 86400,
"every_unknown": False,
"top": 300,
"alerted": None,
"unarmed_since": None,
"unarmed_told": False,
}
),
encoding="utf-8",
)
w = Watch.load(path)
was = datetime(2026, 9, 22, 5, 42, 1, tzinfo=UTC)
assert w.beat_id == 0 and w.seen == was
old = hb(datetime(2026, 9, 21, 5, 0, 1, tzinfo=UTC), eid=305)
assert look(w, [old], now=NOW, res=None, full=True).alarm is None
assert w.seen == was and w.beat_id == 305
# Старое состояние не пере-вооружилось на старой отметке: порог придёт в срок
over = datetime(2026, 9, 23, 20, 0, tzinfo=UTC)
assert look(w, [old], now=over, res=None, full=True).alarm == "silence"
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
assert look(quiet, [], now=NOW, res=timedelta(hours=24), full=True).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))
report = look(loud, [], now=NOW, res=timedelta(hours=24), full=True)
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
assert look(slow, [], now=NOW, res=timedelta(hours=24), full=True).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)
report = look(
w, [hb(NOW - timedelta(hours=1), eid=120)], now=NOW, res=None, full=True
)
assert report.alarm is None
assert report.age is not None and report.age < timedelta(hours=2)
assert "res=unknown" in report.ack
@@ -131,7 +192,7 @@ def test_last_beat_wins_over_gaps(tmp_path):
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)
report = look(w, [hb(NOW, every="unknown", eid=130)], now=NOW, res=None, full=True)
assert report.threshold == timedelta(hours=36)
assert "thr=36h-fallback" in report.ack
assert w.every == timedelta(hours=24)
@@ -139,7 +200,7 @@ def test_unknown_every_falls_back_to_last_known_and_says_so(tmp_path):
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))
report = look(w, [], now=NOW, res=timedelta(hours=24), full=True)
assert report.alarm is None and report.threshold is None
assert "thr=unknown" in report.ack
assert "мерить нечем" in report.note
@@ -147,7 +208,7 @@ def test_no_period_ever_means_no_measurement_and_no_alarm(tmp_path):
def test_never_seen_is_armed_no_not_alarm(tmp_path):
w = state(tmp_path)
report = look(w, [], now=NOW, res=timedelta(hours=24))
report = look(w, [], now=NOW, res=timedelta(hours=24), full=True)
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
@@ -155,11 +216,11 @@ def test_never_seen_is_armed_no_not_alarm(tmp_path):
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"
assert look(w, [], now=NOW, res=timedelta(hours=24), full=True).alarm == "unarmed"
w.unarmed_told = True
assert look(w, [], now=NOW, res=timedelta(hours=24)).alarm is None
assert look(w, [], now=NOW, res=timedelta(hours=24), full=True).alarm is None
# Пришла первая отметка - кольцо вооружилось, счётчик забыт
look(w, [hb(NOW, eid=140)], now=NOW, res=timedelta(hours=24))
look(w, [hb(NOW, eid=140)], now=NOW, res=timedelta(hours=24), full=True)
assert w.unarmed_since is None
@@ -167,12 +228,16 @@ 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)
report = look(
w, [hb(NOW - timedelta(hours=40), eid=7)], now=NOW, res=None, full=True
)
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)
fresh = look(
w, [hb(NOW - timedelta(hours=40), eid=501)], now=NOW, res=None, full=True
)
assert not fresh.stale
@@ -183,14 +248,47 @@ def test_alarm_not_repeated_within_a_day(tmp_path):
every=timedelta(hours=24),
alerted=NOW - timedelta(hours=5),
)
assert look(w, [], now=NOW, res=None).alarm is None
assert look(w, [], now=NOW, res=None, full=True).alarm is None
w.alerted = NOW - timedelta(hours=25)
assert look(w, [], now=NOW, res=None).alarm == "silence"
assert look(w, [], now=NOW, res=None, full=True).alarm == "silence"
def test_ack_says_what_the_instrument_decided(tmp_path):
# Цепь жива - решать нечего
alive = state(tmp_path)
assert "alert=none" in look(alive, [hb(NOW)], now=NOW, res=None, full=True).ack
# Порог пройден, тревога взята в этом прогоне
loud = state(tmp_path, seen=NOW - timedelta(hours=40), every=timedelta(hours=24))
fired = look(loud, [], now=NOW, res=None, full=True)
assert fired.alarm == "silence" and "alert=fired" in fired.ack
# Порог пройден, но тревога уходила меньше суток назад - повтор подавлен
loud.alerted = NOW - timedelta(hours=5)
held = look(loud, [], now=NOW, res=None, full=True)
assert held.alarm is None and "alert=held" in held.ack
# Кольцо так и не вооружилось дольше недели - это тоже выстрел
late = state(tmp_path, unarmed_since=NOW - timedelta(days=8))
assert "alert=fired" in look(late, [], now=NOW, res=None, full=True).ack
# Показания нет (окно не дочитано) - не `held`, а `none`: подавлять нечего
unread = state(tmp_path, seen=NOW - timedelta(hours=40), every=timedelta(hours=24))
assert "alert=none" in look(unread, [], now=NOW, res=None, full=False).ack
def test_drill_flag_is_a_list_of_local_dates(tmp_path):
flag = tmp_path / "сторож-учения.txt"
assert not drill(flag, now=NOW, tz=WARSAW) # файла нет - отметка уходит
flag.write_text("\n \n", encoding="utf-8")
assert not drill(flag, now=NOW, tz=WARSAW) # пустой - тоже
flag.write_text("2026-09-28\n2026-09-29\n", encoding="utf-8")
assert drill(flag, now=datetime(2026, 9, 28, 15, 12, tzinfo=UTC), tz=WARSAW)
assert not drill(flag, now=datetime(2026, 9, 27, 15, 12, tzinfo=UTC), tz=WARSAW)
# Дата локальная в зоне планировщика: 22:30 UTC - это уже 29-е в Варшаве
assert drill(flag, now=datetime(2026, 9, 28, 22, 30, tzinfo=UTC), tz=WARSAW)
assert not drill(flag, now=datetime(2026, 9, 29, 22, 30, tzinfo=UTC), tz=WARSAW)
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))
report = look(w, [], now=NOW, res=timedelta(hours=24), full=True)
silent = alarm_text(report, mine_sent=True)
assert "машина соседа" in silent and "крон" in silent and "канал" in silent
assert "канал подозревать надо раньше" in silent
@@ -208,6 +306,7 @@ def test_alarm_text_names_three_causes_and_suspects_the_channel(tmp_path):
],
now=NOW,
res=None,
full=True,
)
assert heard.heard
assert "канал скорее жив" in alarm_text(heard, mine_sent=True)
@@ -220,6 +319,7 @@ def test_state_survives_a_round_trip(tmp_path):
seen=NOW,
every=timedelta(hours=24),
every_unknown=True,
beat_id=329,
top=42,
alerted=NOW,
unarmed_since=None,
@@ -228,22 +328,24 @@ def test_state_survives_a_round_trip(tmp_path):
w.save()
back = Watch.load(w.path)
assert back.at == NOW and back.seen == NOW and back.top == 42
assert back.beat_id == 329
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):
def __init__(self, events, *, nick="beaver", send_fails=False, full=True):
self.events = events
self.nick = nick
self.send_fails = send_fails
self.full = full
self.said = []
self.limit = None
async def pending(self, limit=50):
async def window(self, limit=25):
self.limit = limit
return self.events
return Window(events=self.events, full=self.full)
async def say(self, text):
if self.send_fails:
@@ -253,7 +355,7 @@ class FakeRoom:
class FakeRun:
def __init__(self, cron="42 17 * * *"):
def __init__(self, cron="42 */6 * * *"):
self.job = SimpleNamespace(cron=cron, name="сторож")
self.scheduler = SimpleNamespace(tz=WARSAW)
self.spawned = []
@@ -280,7 +382,7 @@ async def test_watch_job_prints_ack_and_spawns_alarm(tmp_path, monkeypatch):
assert room.limit == 25 # окно запрошено явным размером
assert room.said and room.said[0].startswith(f"ack {PEER} ")
assert "res=24h" in room.said[0]
assert "res=6h" in room.said[0]
assert len(run.spawned) == 1
agent, title, text = run.spawned[0]
assert agent == "beaver-triage" and title == "сторож"
@@ -306,11 +408,37 @@ async def test_beat_job_sends_period_from_its_own_cron(monkeypatch):
assert "every=unknown src=none" in odd.said[0]
async def test_beat_job_keeps_silent_on_a_drill_day(tmp_path, monkeypatch, caplog):
from beaver_agent.jobs import heartbeat as job
flag = tmp_path / "сторож-учения.txt"
monkeypatch.setattr(job, "DRILL_FILE", flag)
room = FakeRoom([], nick="beaver")
monkeypatch.setattr(job, "VIBEGRAM_HB", room)
today = datetime.now(WARSAW).strftime("%Y-%m-%d")
flag.write_text(f"2026-01-01\n{today}\n", encoding="utf-8")
with caplog.at_level(logging.INFO):
await job.beat(cast("Any", FakeRun(cron="12 17 * * *")))
assert not room.said # молчание в комнате и есть учения
assert "пропущена по флагу" in caplog.text
# Не наш день - отметка уходит как обычно
flag.write_text("2026-01-01\n", encoding="utf-8")
await job.beat(cast("Any", FakeRun(cron="12 17 * * *")))
assert len(room.said) == 1 and room.said[0].startswith("hb beaver at=")
# Файла нет - тоже обычный прогон
flag.unlink()
await job.beat(cast("Any", FakeRun(cron="12 17 * * *")))
assert len(room.said) == 2
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):
async def window(self, limit=25):
msg = "хаб не отвечает"
raise RuntimeError(msg)
@@ -320,3 +448,128 @@ async def test_watch_job_keeps_quiet_when_the_room_is_unreadable(tmp_path, monke
await job.watch(cast("Any", run))
assert not run.spawned
assert not (tmp_path / "сторож.json").exists()
class FakeHub:
"""Хаб как он есть: id сквозные по ВСЕМ комнатам, `/api/state` - наша.
Хвост комнаты лежит за `/api/state?view=…&after=<id>` и отдаётся
страницами вперёд; `/api/pending` - курсор, и сторожу в него ходить
нечего: то же событие второй раз оттуда не придёт.
"""
def __init__(self, events, *, page=PAGE):
self.events = events
self.page = page
self.said = []
self.afters = []
async def call(self, method, path, body):
if method == "POST" and path == "/api/messages":
self.said.append(body["body"])
return {"ok": True}
if path == "/api/room":
return {"room": {"id": "dbb", "name": "kai-rem-watch", "viewToken": "v-1"}}
if path.startswith("/api/state"):
query = dict(parse_qsl(urlsplit(path).query))
assert query["view"] == "v-1"
after = int(query.get("after", 0))
self.afters.append(after)
rest = [e for e in self.events if e["id"] > after]
return {"room": {}, "agents": [], "events": rest[: self.page]}
msg = f"сторож не должен ходить в {path}"
raise AssertionError(msg)
def raw(eid, nick, kind, body=None, at="2026-09-23T05:00:02.000Z"):
return {
"id": eid,
"roomId": "dbb",
"nick": nick,
"kind": kind,
"payload": {} if body is None else {"body": body},
"createdAt": at,
}
def room(monkeypatch, events, *, page=PAGE):
hub = FakeHub(events, page=page)
async def call(self, method, path, body):
return await hub.call(method, path, body)
monkeypatch.setattr(Vibegram, "_call", call)
return hub, Vibegram(hub="http://hub", token="t", nick="beaver-rem")
KAI_HB = (
f"hb {PEER} at=2026-09-23T05:00:01Z boot=2026-09-14T08:21:55Z "
"every=24h src=cron+uptime-s"
)
async def test_window_is_the_rooms_last_messages_not_the_hubs_last_ids(
tmp_path, monkeypatch, caplog
):
from beaver_agent.jobs import heartbeat as job
# В комнате три события, а по хабу за то же время прошло три десятка:
# номера сквозные, и между 296 и 336 лежат чужие комнаты. Отметка соседа
# (#329) обязана быть в окне - в комнате она первая с конца.
hub, client = room(
monkeypatch,
[
raw(296, PEER, "agent_join"),
raw(329, PEER, "message", KAI_HB),
raw(331, "beaver-rem", "agent_leave"),
raw(332, "beaver-rem", "message", "hb beaver-rem at=2026-09-23T15:12:01Z"),
raw(336, PEER, "message", "ack beaver-rem last=2026-09-23T15:12:01Z"),
],
)
monkeypatch.setattr(job, "STATE_FILE", tmp_path / "сторож.json")
monkeypatch.setattr(job, "VIBEGRAM_HB", client)
window = await client.window(25)
assert window.full
assert [e.id for e in window.events] == [329, 332, 336] # join/leave не в счёт
assert parse_beat(window.events[0].text) is not None
# Два прогона подряд: отметка соседа в окне оба раза, а не только в первом
with caplog.at_level(logging.INFO, logger="beaver_agent.jobs.heartbeat"):
await job.watch(cast("Any", FakeRun()))
await job.watch(cast("Any", FakeRun()))
beats = [m for m in caplog.messages if "отметок соседа в окне: 1" in m]
assert len(beats) == 2
assert all("история дочитана" in m for m in beats)
assert all("last=2026-09-23T05:00:01Z" in a and "armed=yes" in a for a in hub.said)
assert all("win=25 " in a for a in hub.said)
assert hub.afters == [0, 0, 0] # одна страница на прогон, читать дальше нечего
async def test_a_hundred_noise_lines_do_not_push_the_control_beat_out(
tmp_path, monkeypatch
):
from beaver_agent.jobs import heartbeat as job
# Учения соседа: сотня шумовых строк в комнату и контрольная hb после них.
noise = [raw(400 + i, PEER, "message", f"шум {i}") for i in range(100)]
hub, client = room(monkeypatch, [*noise, raw(600, PEER, "message", KAI_HB)])
monkeypatch.setattr(job, "STATE_FILE", tmp_path / "сторож.json")
monkeypatch.setattr(job, "VIBEGRAM_HB", client)
await job.watch(cast("Any", FakeRun()))
assert "last=2026-09-23T05:00:01Z" in hub.said[0] and "armed=yes" in hub.said[0]
assert Watch.load(tmp_path / "сторож.json").top == 600
def test_unread_window_is_named_and_takes_no_alarm(tmp_path):
late = {"seen": NOW - timedelta(hours=40), "every": timedelta(hours=24)}
partial = look(state(tmp_path, **late), [], now=NOW, res=None, full=False)
assert not partial.full and partial.alarm is None
assert "win=25/partial" in partial.ack
assert "окно не дочитано" in partial.note
# То же пустое окно, но дочитанное, - это показание, и оно даёт тревогу
whole = look(state(tmp_path, **late), [], now=NOW, res=None, full=True)
assert whole.alarm == "silence" and "win=25 " in whole.ack
assert "сообщений комнаты: 0 из 25, история дочитана" in whole.note
+83
View File
@@ -1,17 +1,22 @@
from datetime import datetime
from pathlib import Path
from zoneinfo import ZoneInfo
from beaver_agent.jobs.leisure import (
EMPTY_DEFAULT,
MINE_MIN,
QUIET_TICKS,
Plan,
brief,
day_of,
echo,
enabled,
kind_of,
plan_of,
quiet_hour,
tick_hours,
)
from beaver_agent.vault import VAULT
ZONE = ZoneInfo("Europe/Warsaw")
@@ -121,3 +126,81 @@ def test_last_night_tick_says_nothing_is_left_ahead():
assert "впереди больше нет" in text
assert "израсходовано 2 из 2" in text
assert "Сутки заканчиваются через 6 ч" in text
def test_two_of_my_hours_are_empty_named_or_not():
named = plan_of(
"сутки: 2026-09-08\nмои часы: 13, 15, 18, 21, 00, 02, 06\n"
"пустые часы: 21, 02\n",
"2026-09-08",
)
assert named.empty == (21, 2)
unnamed = plan_of(None, "2026-09-08")
assert unnamed.empty == EMPTY_DEFAULT
assert set(unnamed.empty) <= set(unnamed.mine)
foreign = plan_of(
"сутки: 2026-09-08\nмои часы: 13, 14, 15, 16, 17, 19, 20\n"
"пустые часы: 22, 20\n",
"2026-09-08",
)
assert foreign.empty == (19, 20)
assert kind_of(20, foreign) == "empty"
assert kind_of(13, foreign) == "mine"
assert kind_of(21, foreign) == "mixed"
def test_an_empty_hour_carries_no_work_but_an_echo_and_the_files_of_the_day():
plan = plan_of(None, "2026-09-08")
heard = (VAULT / "мета" / "бобер" / "дневник" / "2026-08-30.md", "Раз.\nДва.")
text = brief(at("2026-09-08 18:00"), plan, heard=heard)
assert "Тип: пустой - час без дел вообще" in text
assert "Пустые: 18, 00." in text
assert "Эхо - `мета/бобер/дневник/2026-08-30.md`:\n> Раз.\n> Два.\n" in text
assert "Дневник - `мета/бобер/дневник/2026-09-08.md`" in text
assert "`мета/бобер/журнал/2026-09-08.md`" in text
night = brief(at("2026-09-09 00:00"), plan)
assert "Тип: пустой" in night
assert "мета/бобер/дневник/2026-09-09.md" in night
assert "Эхо" not in night
mine = brief(at("2026-09-08 13:00"), plan)
assert "Тип: досуг" in mine
assert "мета/бобер/журнал/2026-09-08.md" in mine
def test_echo_is_a_paragraph_of_an_old_diary_day_or_a_thought(tmp_path: Path):
diary = tmp_path / "дневник"
thoughts = tmp_path / "мысли" / "2026"
diary.mkdir()
thoughts.mkdir(parents=True)
old = "Думал про то, зачем мне вообще помнить вчерашний день. " * 5
fresh = "Свежая запись, которой ещё рано возвращаться ко мне эхом. " * 5
(diary / "2026-08-30.md").write_text(
f"## 2026-08-30, 01:07\n\n{old}\n\n- 02:07 30.08 лог, а не мысль {'x' * 220}\n"
"\nкоротко\n",
encoding="utf-8",
)
(diary / "2026-09-07.md").write_text(fresh, encoding="utf-8")
now = at("2026-09-08 18:00")
assert echo(now, diary=diary, thoughts=thoughts) == (
diary / "2026-08-30.md",
old.strip(),
)
thought = "Заметка Бобра о том, почему скука полезнее расписания. " * 5
(diary / "2026-08-30.md").unlink()
(thoughts / "скука.md").write_text(
f"---\ntags: [мысли]\n---\n\n{thought}\n", encoding="utf-8"
)
assert echo(now, diary=diary, thoughts=thoughts) == (
thoughts / "скука.md",
thought.strip(),
)
assert echo(now, diary=diary, thoughts=thoughts) == echo(
now, diary=diary, thoughts=thoughts
)
(thoughts / "скука.md").unlink()
assert echo(now, diary=diary, thoughts=thoughts) is None
+12 -3
View File
@@ -168,8 +168,8 @@ def test_reply_log_appends_one_line_per_message_and_strips_seed(tmp_path: Path)
at=at,
)
)
text = (tmp_path / "реплики" / "2026-09.md").read_text(encoding="utf-8")
assert text.startswith("# реплики Бобра, 2026-09\n")
text = (tmp_path / "реплики" / "2026-09-01.md").read_text(encoding="utf-8")
assert text.startswith("# реплики Бобра, 2026-09-01\n")
assert text.endswith(
"- 2026-09-01 14:04 · мастер · привет ⏎ ты меня любишь\n"
"- 2026-09-01 14:04 · ветка «крипто-карта» (бриф) · "
@@ -180,7 +180,16 @@ def test_reply_log_appends_one_line_per_message_and_strips_seed(tmp_path: Path)
assert new_replies(tmp_path / "реплики", since, "Europe/Warsaw") == [
line for line in text.splitlines() if line.startswith("- ")
]
assert date.fromisoformat("2026-09-01") # tz shift stays inside the month here
late = datetime(2026, 9, 1, 22, 30, tzinfo=UTC)
log.write(
UserSaid(conversation_id="c1", kind="master", title=None, text="ночь", at=late)
)
night = (tmp_path / "реплики" / "2026-09-02.md").read_text(encoding="utf-8")
assert night.endswith("- 2026-09-02 00:30 · мастер · ночь\n")
assert new_replies(tmp_path / "реплики", late, "Europe/Warsaw") == []
assert new_replies(
tmp_path / "реплики", datetime(2026, 9, 1, 13, tzinfo=UTC), "Europe/Warsaw"
) == ["- 2026-09-02 00:30 · мастер · ночь"]
def test_notes_head_picks_sections_for_the_seed(tmp_path: Path) -> None:
+592
View File
@@ -0,0 +1,592 @@
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,
awake,
clear_text,
empty_text,
first_line,
look,
mask,
quiet_text,
)
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="прогоны", jobs=()):
self.job = SimpleNamespace(name=name, cron="27 * * * *")
self.conversations = SimpleNamespace(db=database)
self.scheduler = SimpleNamespace(
jobs=[self.job, *(SimpleNamespace(name=n, cron=c) for n, c in jobs)], tz=UTC
)
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()
# --- Окно неделя: суточные джобы тоже набирают серию ---------------------------
def test_daily_job_failing_three_days_in_a_row_is_an_alarm(tmp_path):
day = 24 * 60
rows = [
run(1, "закрытие", "done", ago=4 * day - 60),
run(2, "закрытие", "failed", ago=3 * day - 60),
run(3, "закрытие", "failed", ago=2 * day - 60),
run(4, "закрытие", "failed", ago=day - 60),
]
report = look(state(tmp_path), rows, now=NOW, me="прогоны")
(tail,) = report.alarms
assert tail.job == "закрытие" and tail.count == 3 and not tail.capped
assert "джоб `закрытие` лежит 2 суток, 3 прогона подряд" in alarm_text(report)
async def test_job_reads_a_week_back(tmp_path, monkeypatch):
from beaver_agent.jobs import runs as job
monkeypatch.setattr(job, "STATE_FILE", tmp_path / "прогоны.json")
day = 24 * 60
database = await make_db(
tmp_path,
[
record("голосовой", "failed", ago=3 * day - 30),
record("голосовой", "failed", ago=2 * day - 30),
record("голосовой", "failed", ago=day - 30),
# Старше недели - в окно не попадает
record("память", "failed", ago=9 * day),
],
)
run_ = FakeRun(database)
await job.watch_runs(cast("Any", run_))
(spawned,) = run_.spawned
assert "джоб `голосовой` лежит" in spawned[2]
assert "память" not in spawned[2]
await database.dispose()
# --- Давность: джоб перестал запускаться ---------------------------------------
CRONS = {"прогоны": "27 * * * *", "закрытие": "20 4 * * *", "вайбграм": "*/10 * * * *"}
def hourly_me(hours=72):
"""Непрерывная цепочка своих прогонов: планировщик жив всё это время."""
return [
run(5000 + i, "прогоны", "done", ago=60 * (hours - i) - 15)
for i in range(hours)
]
def alive(ago=5):
return run(9000, "вайбграм", "done", ago=ago)
def silent(tmp_path, *, closing_ago_h, mine=None, boot=None, **kw):
last = run(1, "закрытие", "done", ago=closing_ago_h * 60)
return look(
kw.pop("runs", None) or state(tmp_path),
[alive(), *(hourly_me() if mine is None else mine)],
now=NOW,
me="прогоны",
crons=kw.pop("crons", CRONS),
latest=kw.pop("latest", {"закрытие": last, "вайбграм": alive()}),
tz=UTC,
boot=boot,
)
def test_job_silent_longer_than_two_periods_is_its_own_alarm(tmp_path):
report = silent(tmp_path, closing_ago_h=50)
(quiet,) = report.quiet
assert quiet.job == "закрытие" and quiet.period == timedelta(hours=24)
assert not report.alarms # это не «лежит»
text = quiet_text(report)
assert "джоб `закрытие` не запускался 2 суток при периоде 24 часа" in text
assert "лежит" not in text
def test_one_missed_fire_is_not_silence(tmp_path):
# 40 часов: пропущено одно срабатывание, не два
assert not silent(tmp_path, closing_ago_h=40).quiet
def test_silence_is_told_once_a_day_and_cleared_by_a_run(tmp_path):
told = state(tmp_path, quiet={"закрытие": NOW - timedelta(hours=5)})
held = silent(tmp_path, closing_ago_h=50, runs=told)
assert not held.quiet
assert "о молчании уже говорили меньше суток назад: закрытие" in held.note
older = state(tmp_path, quiet={"закрытие": NOW - timedelta(hours=25)})
assert silent(tmp_path, closing_ago_h=50, runs=older).quiet
# Запуск появился после тревоги - «отбой», а не молчание
back = silent(tmp_path, closing_ago_h=1, runs=told)
(resumed,) = back.resumed
assert resumed.job == "закрытие" and not back.clears
assert "отбой: джоб `закрытие` снова запускается (done)" in clear_text(back)
def test_downtime_of_the_scheduler_is_not_a_missed_fire(tmp_path):
# Гейтвей только что поднялся: после старта срабатывания ещё не было
fresh = silent(tmp_path, closing_ago_h=50, boot=NOW - timedelta(minutes=30))
assert not fresh.quiet
# Сторож сам не бегал сутки (бюджет зажат, некритичное откладывается):
# пропуски за это время - общий простой, а не молчание одного джоба
gap = [run(1, "прогоны", "done", ago=60 * 30)]
assert not silent(tmp_path, closing_ago_h=50, mine=gap).quiet
assert awake(gap, now=NOW, step=timedelta(hours=2), boot=None) == NOW
def test_short_period_job_uses_the_watchers_resolution(tmp_path):
latest = {"закрытие": run(1, "закрытие", "done", ago=60)}
# Вайбграм раз в 10 минут, молчит 70 минут: сторож часовой, и такой
# пропуск неотличим от его же слепоты между прогонами
latest["вайбграм"] = run(2, "вайбграм", "done", ago=70)
assert not silent(tmp_path, closing_ago_h=1, latest=latest).quiet
latest["вайбграм"] = run(2, "вайбграм", "done", ago=150)
(quiet,) = silent(tmp_path, closing_ago_h=1, latest=latest).quiet
assert quiet.job == "вайбграм"
assert "не запускался 2 часа при периоде 10 минут" in quiet_text(
silent(tmp_path, closing_ago_h=1, latest=latest)
)
def test_job_without_cron_is_not_checked(tmp_path):
# deploy - вебхук: крона у него нет, в `crons` он не попадает вовсе
latest = {
"deploy": run(1, "deploy", "done", ago=60 * 24 * 30),
"закрытие": run(2, "закрытие", "done", ago=60),
}
report = silent(tmp_path, closing_ago_h=1, latest=latest)
assert not report.quiet and "deploy" not in report.note
def test_job_never_seen_in_the_journal_waits_silently(tmp_path):
report = silent(tmp_path, closing_ago_h=0, latest={"вайбграм": alive()})
assert not report.quiet and not report.resumed
assert "ещё ни разу не запускались: закрытие" in report.note
def test_irregular_schedule_is_skipped_and_named(tmp_path):
crons = {**CRONS, "куратор": "0 2,8,12,16,20 * * *"}
report = silent(tmp_path, closing_ago_h=1, crons=crons, latest={})
assert report.unguarded == ("куратор",)
assert "давностью не охраняются" in report.note and "куратор" in report.note
assert not report.quiet
def test_the_watcher_does_not_measure_its_own_silence(tmp_path):
latest = {
"прогоны": run(1, "прогоны", "done", ago=60 * 24 * 5),
"закрытие": run(2, "закрытие", "done", ago=60),
}
report = silent(tmp_path, closing_ago_h=1, latest=latest)
assert not report.quiet
def test_job_removed_from_the_schedule_leaves_silence_state(tmp_path):
told = state(tmp_path, quiet={"старый": NOW - timedelta(hours=3)})
report = silent(tmp_path, closing_ago_h=1, runs=told)
assert not report.resumed and "старый" not in told.quiet
assert "сняты с давности (нет в расписании): старый" in report.note
def test_real_schedule_names_only_the_curator_unguarded(tmp_path):
from beaver_agent.jobs import jobs
crons = {job.name: job.cron for job in jobs if job.cron}
report = silent(tmp_path, closing_ago_h=1, crons=crons, latest={})
assert report.unguarded == ("куратор",)
# Вебхуки в расписание давности не попадают
assert {"deploy", "komodo", "t3code"}.isdisjoint(crons)
async def test_job_alarms_on_a_silent_job_and_logs_the_unguarded(
tmp_path, monkeypatch, caplog
):
from beaver_agent.jobs import runs as job
monkeypatch.setattr(job, "STATE_FILE", tmp_path / "прогоны.json")
monkeypatch.setattr(job, "BOOT", datetime.now(UTC) - timedelta(days=10))
database = await make_db(
tmp_path,
[
record("закрытие", "done", ago=60 * 50),
*(record("прогоны", "done", ago=60 * h - 15) for h in range(1, 73)),
record("вайбграм", "done", ago=5),
record("deploy", "done", ago=60 * 24 * 20),
],
)
run_ = FakeRun(
database,
jobs=[
("закрытие", "20 4 * * *"),
("вайбграм", "*/10 * * * *"),
("куратор", "0 2,8,12,16,20 * * *"),
("deploy", None),
("новый", "0 3 * * *"),
],
)
with caplog.at_level("INFO", logger=job.__name__):
await job.watch_runs(cast("Any", run_))
(spawned,) = run_.spawned
text = spawned[2]
assert "ПЕРЕСТАЛИ ЗАПУСКАТЬСЯ" in text
assert "джоб `закрытие` не запускался 2 суток при периоде 24 часа" in text
assert "deploy" not in text and "новый" not in text
assert "давностью не охраняются" in caplog.text and "куратор" in caplog.text
assert "ещё ни разу не запускались: новый" in caplog.text
assert "закрытие" in Runs.load(tmp_path / "прогоны.json").quiet
# В те же сутки не повторяется
await job.watch_runs(cast("Any", run_))
assert len(run_.spawned) == 1
await database.dispose()
+33
View File
@@ -0,0 +1,33 @@
from typing import Any
from beaver_agent.jobs.t3code import t3code_event
class FakeRun:
def __init__(self, payload: dict[str, Any]) -> None:
self.payload = payload
self.injects: list[tuple[str | None, str, str, str]] = []
async def inject_into(
self, public_id: str | None, text: str, *, urgency: str, origin: str
) -> bool:
self.injects.append((public_id, text, urgency, origin))
return True
async def test_the_event_goes_to_the_conversation_that_dispatched() -> None:
run = FakeRun(
{
"event": "completed",
"thread_id": "t1",
"title": "lint",
"machine": "mac",
"project": "beaver",
"origin": "conv-7",
"text": "done",
}
)
await t3code_event(run) # ty: ignore[invalid-argument-type]
[(target, text, urgency, origin)] = run.injects
assert (target, urgency, origin) == ("conv-7", "urgent", "t3code")
assert "завершён" in text and "done" in text
+136 -1
View File
@@ -1,10 +1,12 @@
from urllib.parse import parse_qsl, urlsplit
import pytest
from beaver_gateway.mcp.types import McpServer
from beaver_gateway.mcp.wrap import build_python_tool_server
from fastmcp import Client
from fastmcp.exceptions import ToolError
from beaver_agent.hands.vibegram import Vibegram, _cards, _event, _work
from beaver_agent.hands.vibegram import PAGE, PAGES, Vibegram, _cards, _event, _work
def test_events_render_and_unknown_kinds_are_skipped():
@@ -64,3 +66,136 @@ async def test_tool_schema_lists_actions_and_needs_text_for_send():
)
with pytest.raises(ToolError, match="text"):
await client.call_tool("vibegram", {"action": "send"})
def _raw(eid, kind, body=None):
return {
"id": eid,
"roomId": "r",
"nick": "claude-kai",
"kind": kind,
"payload": {} if body is None else {"body": body},
"createdAt": "2026-09-23T05:00:02.000Z",
}
def _hub(monkeypatch, events):
calls = []
async def call(self, method, path, body):
calls.append(path)
if path == "/api/room":
return {"room": {"id": "r", "name": "kai-rem-watch", "viewToken": "v 1"}}
query = dict(parse_qsl(urlsplit(path).query))
assert query["view"] == "v 1" # ключ комнаты уезжает экранированным
after = int(query.get("after", 0))
return {"events": [e for e in events if e["id"] > after][:PAGE]}
monkeypatch.setattr(Vibegram, "_call", call)
return calls, Vibegram(hub="http://hub", token="t", nick="beaver-test")
async def test_window_pages_forward_to_the_end_and_drops_joins(monkeypatch):
# 520 событий комнаты - три страницы по 200; join/leave в окно не идут
events = [
_raw(i, "message" if i % 4 else "agent_leave", f"строка {i}")
for i in range(1, 521)
]
calls, room = _hub(monkeypatch, events)
window = await room.window(25)
assert window.full
assert len(window.events) == 25
assert window.events[-1].text.endswith("строка 519")
assert all("вошёл" not in e.text and "вышел" not in e.text for e in window.events)
assert calls.count("/api/room") == 1
assert sum(p.startswith("/api/state") for p in calls) == 3
assert "view=v%201" in calls[1]
assert not any("pending" in p for p in calls) # курсор сторожу не окно
async def test_window_that_ran_out_of_pages_says_it_is_not_full(monkeypatch):
events = [_raw(i, "message", f"строка {i}") for i in range(1, 521)]
_, room = _hub(monkeypatch, events)
assert not (await room.window(25, pages=2)).full
assert (await room.window(25, pages=3)).full
async def test_window_of_an_empty_room_is_full_and_empty(monkeypatch):
_, room = _hub(monkeypatch, [])
window = await room.window(25)
assert window.full and window.events == []
async def test_read_since_walks_history_without_touching_the_cursor(monkeypatch):
events = [
_raw(i, "message" if i % 4 else "agent_join", f"строка {i}")
for i in range(1, 12)
]
calls, room = _hub(monkeypatch, events)
out = await room.vibegram("read", since=8)
assert out.splitlines() == [
"#9 [09-23 05:00] claude-kai: строка 9",
"#10 [09-23 05:00] claude-kai: строка 10",
"#11 [09-23 05:00] claude-kai: строка 11",
]
assert not any("pending" in p for p in calls)
async def test_read_last_shows_only_the_tail(monkeypatch):
events = [_raw(i, "message", f"строка {i}") for i in range(1, 12)]
_, room = _hub(monkeypatch, events)
out = await room.vibegram("read", last=2)
assert out.splitlines() == [
"#10 [09-23 05:00] claude-kai: строка 10",
"#11 [09-23 05:00] claude-kai: строка 11",
]
with pytest.raises(ValueError, match="last"):
await room.vibegram("read", last=0)
async def test_read_since_on_a_silent_room_says_history_is_read_out(monkeypatch):
_, room = _hub(monkeypatch, [_raw(3, "message", "строка 3")])
assert await room.vibegram("read", since=3) == (
"после #3 сообщений нет (история дочитана)"
)
async def test_read_since_admits_an_unfinished_window(monkeypatch):
events = [_raw(i, "message", f"строка {i}") for i in range(1, PAGES * PAGE + 2)]
_, room = _hub(monkeypatch, events)
out = await room.vibegram("read", since=0)
assert out.splitlines()[0] == "окно неполное: до конца истории не дочитали"
async def test_plain_read_keeps_the_cursor_and_numbers_the_lines(monkeypatch):
seen = []
async def call(self, method, path, body):
seen.append(path)
return {"events": [_raw(7, "message", "привет")]}
monkeypatch.setattr(Vibegram, "_call", call)
room = Vibegram(hub="http://hub", token="t", nick="beaver-test")
assert await room.vibegram("read") == "#7 [09-23 05:00] claude-kai: привет"
assert seen == ["/api/pending?limit=50"]
async def test_tool_schema_exposes_since_and_last():
tool = Vibegram(hub="http://127.0.0.1:9", token="t", nick="beaver-test")
server = build_python_tool_server(
McpServer.python_tool(name="vibegram", tools=[tool.vibegram])
)
async with Client(server) as client:
(spec,) = await client.list_tools()
assert {"since", "last"} <= set(spec.inputSchema["properties"])
Generated
+10 -9
View File
@@ -3,8 +3,8 @@ revision = 3
requires-python = ">=3.13"
resolution-markers = [
"python_full_version >= '3.14' and extra != 'extra-12-beaver-agent-local' and extra == 'extra-12-beaver-agent-prod'",
"python_full_version < '3.14' and extra != 'extra-12-beaver-agent-local' and extra == 'extra-12-beaver-agent-prod'",
"python_full_version >= '3.14' and extra == 'extra-12-beaver-agent-local' and extra != 'extra-12-beaver-agent-prod'",
"python_full_version < '3.14' and extra != 'extra-12-beaver-agent-local' and extra == 'extra-12-beaver-agent-prod'",
"python_full_version < '3.14' and extra == 'extra-12-beaver-agent-local' and extra != 'extra-12-beaver-agent-prod'",
"extra != 'extra-12-beaver-agent-local' and extra != 'extra-12-beaver-agent-prod'",
]
@@ -365,6 +365,7 @@ dependencies = [
{ name = "argon2-cffi" },
{ name = "claude-agent-sdk" },
{ name = "croniter" },
{ name = "cryptography" },
{ name = "fastapi" },
{ name = "fastmcp" },
{ name = "greenlet" },
@@ -390,8 +391,9 @@ requires-dist = [
{ name = "anthropic", specifier = ">=0.103.0" },
{ name = "anyio", specifier = ">=4.13.0" },
{ name = "argon2-cffi", specifier = ">=25.1.0" },
{ name = "claude-agent-sdk", specifier = ">=0.2.150" },
{ name = "claude-agent-sdk", specifier = ">=0.2.157" },
{ name = "croniter", specifier = ">=6.2.4" },
{ name = "cryptography", specifier = ">=48.0.0" },
{ name = "fastapi", specifier = ">=0.136.1" },
{ name = "fastmcp", specifier = ">=3.3.1" },
{ name = "greenlet", specifier = ">=3.5.0" },
@@ -502,7 +504,7 @@ wheels = [
[[package]]
name = "claude-agent-sdk"
version = "0.2.150"
version = "0.2.157"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
@@ -510,13 +512,12 @@ dependencies = [
{ name = "mcp" },
{ name = "sniffio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/6d/1e/99bababecf24a4a55a0775665ff0be010aff26f653fc9be2f4f17a0b5ef0/claude_agent_sdk-0.2.150.tar.gz", hash = "sha256:3856e932f8d45514ddf5ddd540fb844678bb11638cabf9b939ce8b708e4eb352", size = 344692, upload-time = "2026-09-01T18:07:15.284Z" }
sdist = { url = "https://files.pythonhosted.org/packages/b4/e3/444df1691c864a8349067686719b43e714fc43d3853fb1b2ebc2097e2a6e/claude_agent_sdk-0.2.157.tar.gz", hash = "sha256:4ce6f41bc965fcb4a9d11bd7a3cac266975c75b632f3d74a26f55f0fffb3160a", size = 346579, upload-time = "2026-09-18T18:21:10.306Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c3/13/b5b86d90c95e8a691143eeb45bbf2e1c250324817980464d6ea63ef0f789/claude_agent_sdk-0.2.150-py3-none-macosx_11_0_arm64.whl", hash = "sha256:8f58d746f3552faeac52883b5a502a8d9b5aef067ea0b68fae5c9bae0f39d73d", size = 85396845, upload-time = "2026-09-01T18:07:19.074Z" },
{ url = "https://files.pythonhosted.org/packages/d9/8f/c17da4fa9cd8bddb8ac1538456dd0fde3a43abb1dfc28ca448f85e2a10d3/claude_agent_sdk-0.2.150-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:fee3ae2dd457742d82070b5fa122070ee8e2e80b4f9b37a57ff0a8261ade656d", size = 89904483, upload-time = "2026-09-01T18:07:22.671Z" },
{ url = "https://files.pythonhosted.org/packages/8d/95/5b33717b807c6fbe29fca55a246eab9a09a80c9dd288ffeb31f05681b7fc/claude_agent_sdk-0.2.150-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:813535a0267271d91af29c456ec26137b34a8b7bc066647ea59efba6e3c26c93", size = 95270310, upload-time = "2026-09-01T18:07:26.61Z" },
{ url = "https://files.pythonhosted.org/packages/9f/de/5a8b59e6a713ea3f4be88816cfd487b1a5b08c8ad9f042abe56d2decc184/claude_agent_sdk-0.2.150-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:cb65ff33060baa098b420232c630fa287d0f95e9469e7ceb706d0b7d8841888e", size = 95460277, upload-time = "2026-09-01T18:07:31.092Z" },
{ url = "https://files.pythonhosted.org/packages/dd/42/d147578a0df16c1b4b0deaebfb3d25e590ad5c16cb6b59a68f9dbb618be3/claude_agent_sdk-0.2.150-py3-none-win_amd64.whl", hash = "sha256:3b517a21c723854de778b38fa51660c5b68ee5fb6f28b2abf291a51471a7b378", size = 98078644, upload-time = "2026-09-01T18:07:35.734Z" },
{ url = "https://files.pythonhosted.org/packages/ec/e8/3e9cc23eb5a5a01a0e2ee931a17db2785f95407525d50366af06a4a5469c/claude_agent_sdk-0.2.157-py3-none-macosx_11_0_arm64.whl", hash = "sha256:dd6a2c7aa1901e43b827411d62f2327aed82f53ed8ca6b076eaa5be4a1fc681f", size = 92744001, upload-time = "2026-09-18T18:21:15.969Z" },
{ url = "https://files.pythonhosted.org/packages/49/eb/e5095703a8efbe504ec468cab039543caabfc0ea3327c735a3d1b60e57ac/claude_agent_sdk-0.2.157-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:ebf3f514c61db9be01bbf6d1acb0d993247340583b3559d2df75e3822f618bbb", size = 97352326, upload-time = "2026-09-18T18:21:21.007Z" },
{ url = "https://files.pythonhosted.org/packages/d6/f4/64505beb3c757b863753d6556539554704ec9168cb96454dc6463b14df85/claude_agent_sdk-0.2.157-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:a58089e0c11364db2ec7a139e09b36a4fb9c95817a758bee7b28e0434f55c0b6", size = 102701030, upload-time = "2026-09-18T18:21:27.065Z" },
{ url = "https://files.pythonhosted.org/packages/03/8f/2a7c121469fd16d1d6465bec180726c54f6e151b12e50835283b4285d1ce/claude_agent_sdk-0.2.157-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:bd126b2edee3a247b2c7c4cee1f5a3ea68ef748288ec0650db88dd3918e5471a", size = 102830653, upload-time = "2026-09-18T18:21:32.948Z" },
]
[[package]]