fix(hands,jobs): watchdog window reads the room tail, not the pending cursor

This commit is contained in:
hh committed 2026-09-23 18:18:27 +00:00
1 parent 91bd50a66d
commit 47c31ac356
6 files changed
+341 -37

No files matched your search

+18 -5
View File
@@ -216,9 +216,9 @@ 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
@@ -229,8 +229,9 @@ 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.
- `сторож` (`:42` every six hours) 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.
@@ -242,6 +243,18 @@ are no defaults anywhere in it.
- 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.
+37 -6
View File
@@ -12,7 +12,13 @@
даёт честное `every=unknown`, а не подставленное число. Порог тревоги -
`every * 1.5`, где `every` взят ИЗ СТРОКИ СОСЕДА, а не из своей константы.
Каждый прогон, в котором показание снять нельзя (окно смотрит в прошлое,
период неизвестен, отметок не видели ни разу), говорит это вслух в `ack`.
окно не дочитано, период неизвестен, отметок не видели ни разу), говорит
это вслух в `ack` и в лог: «в окне никого» и «окно не прочитано до конца» -
это два РАЗНЫХ показания, и выглядеть одинаково они не должны.
Окно - последние `WINDOW` СООБЩЕНИЙ этой комнаты (`Vibegram.window`), а не
«что нового с курсора»: курсор отдаёт строку соседа один раз, и следующий
прогон на живой комнате увидел бы пустое окно.
Опора тревоги - `seen`, то есть когда Я увидел строку: `at=` внутри строки
это показание соседа о себе, и остановившиеся у него часы не должны
@@ -56,8 +62,8 @@ __all__ = [
PEER = "claude-kai"
"""Чей `hb` сторожим. Ник соседа стоит и в его строке, и в нашем `ack`."""
WINDOW = 25
"""Сколько событий просим у хаба явно: у `pending` свой дефолт (50), и молча
опираться на чужой дефолт в стороже нельзя - размер окна едет в `ack`."""
"""Сколько последних сообщений комнаты просим явно: молча опираться на чужой
дефолт в стороже нельзя - размер окна едет в `ack`."""
FACTOR = 1.5
"""Порог молчания = заявленный соседом период × это. Одна потерянная строка
не должна поднимать тревогу, две подряд - должны."""
@@ -201,13 +207,20 @@ def ack_line(
armed: bool,
threshold: timedelta | None,
fallback: bool,
full: bool,
) -> str:
"""Расписка о прогоне в комнату: что видели, чем мерили и вооружены ли."""
"""Расписка о прогоне в комнату: что видели, чем мерили и вооружены ли.
`win=` - про само окно: `25` это «последние 25 сообщений комнаты прочитаны
целиком», `25/partial` - «дочитать историю не удалось», `stale` - «окно
смотрит в прошлое». Пустое окно при `partial` ничего не доказывает.
"""
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"win={read} armed={'yes' if armed else 'no'} "
f"thr={mark}"
)
@@ -276,6 +289,8 @@ class Report:
ack: str
stale: bool
full: bool
"""Окно дочитано до конца истории комнаты: пустое окно - это показание."""
armed: bool
heard: bool
"""Видели ли в окне хоть что-то от соседа - `hb` или его `ack`."""
@@ -292,11 +307,16 @@ def look(
*,
now: datetime,
res: timedelta | None,
full: bool,
window: int = WINDOW,
peer: str = PEER,
) -> Report:
"""Разобрать окно, обновить состояние и решить, будить ли человека.
`events` - последние сообщения комнаты, `full` - дочитаны ли они до конца
истории. Параметр без дефолта нарочно: «окно полное» - это показание, и
подставлять его молча за того, кто окно читал, здесь нельзя.
Состояние меняется здесь, а `alerted` / `unarmed_told` ставит джоб - и
только когда тревога действительно ушла: несостоявшаяся доставка не
должна заглушать следующий прогон.
@@ -320,7 +340,11 @@ def look(
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})"
)
armed = state.seen is not None
threshold = state.every * FACTOR if state.every is not None else None
@@ -334,6 +358,11 @@ def look(
alarm: Literal["silence", "unarmed"] | None = None
if stale:
pass
elif not full:
# Хвост комнаты недочитан, а недостающее - как раз самое свежее:
# пустое окно тут не доказывает молчания соседа, оно доказывает,
# что мы не дочитали. Показания нет - тревоги нет.
note += "; окно не дочитано - это не показание, тревога не берётся"
elif not armed:
if (
not state.unarmed_told
@@ -360,8 +389,10 @@ def look(
armed=armed,
threshold=threshold,
fallback=state.every_unknown,
full=full,
),
stale=stale,
full=full,
armed=armed,
heard=heard,
age=age,
+59 -1
View File
@@ -10,6 +10,10 @@
`/api/pending` - курсор на стороне хаба: прочитанное второй раз не
приходит. Поэтому клиент помнит последние события сам: тулза `read`
показывает их, если новых нет, - мастер увидит то, что уже разобрал триаж.
Там, где нужен не «что нового», а «последние N строк комнаты» (сторож),
курсор не годится в принципе: `window` читает хвост истории через
`/api/state`, не трогая курсор и ничего не вычёркивая.
"""
from __future__ import annotations
@@ -20,16 +24,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)
@@ -44,6 +55,16 @@ class Event:
return f"[{self.at}] {self.text}"
@dataclass(frozen=True, slots=True)
class Window:
"""Хвост комнаты: последние сообщения и честный признак полноты."""
events: list[Event]
full: bool
"""История дочитана до конца: в `events` действительно ПОСЛЕДНИЕ сообщения.
Ложь здесь - это «в окне никого» там, где окно просто не дочитано."""
@dataclass(slots=True)
class Vibegram:
hub: str
@@ -151,6 +172,43 @@ 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` спрашиваем каждый раз заново: протухший в кэше ключ - ровно
то тихое враньё, ради которого окно и переписано.
"""
view = quote(str((await self._get("/api/room"))["room"]["viewToken"]))
tail: deque[Event] = deque(maxlen=limit)
after = 0
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:
"""Написать в комнату; сбой хаба - исключение, а не строка.
+5 -3
View File
@@ -7,7 +7,8 @@
он выведен из крона ЭТОГО ЖЕ джоба (`run.job.cron`) - что зарегистрировано
у планировщика, то и написано в строке. Не сводится к «раз в N часов» -
пишем `every=unknown`, потому что соврать числом хуже, чем отказаться;
- `watch` читает окно комнаты, сверяет возраст последней отметки соседа с
- `watch` читает окно - последние `WINDOW` сообщений САМОЙ комнаты, а не
«что нового с курсора», - сверяет возраст последней отметки соседа с
порогом из ЕГО ЖЕ строки, печатает в комнату `ack` и - если цепь молчит -
отдаёт тревогу мастеру через `run.spawn_job`, как это делает триаж
вайбграма: будить ли владельца, решает диспетчер.
@@ -78,7 +79,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 +89,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:
+158 -21
View File
@@ -1,6 +1,8 @@
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 (
@@ -14,7 +16,7 @@ from beaver_agent.hands.heartbeat import (
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 +102,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
@@ -111,19 +115,21 @@ def test_fresh_beat_arms_and_stays_quiet(tmp_path):
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 +137,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 +145,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 +153,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 +161,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 +173,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 +193,14 @@ 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_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 +218,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)
@@ -234,16 +245,17 @@ def test_state_survives_a_round_trip(tmp_path):
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:
@@ -310,7 +322,7 @@ async def test_watch_job_keeps_quiet_when_the_room_is_unreadable(tmp_path, monke
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 +332,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
+64 -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, Vibegram, _cards, _event, _work
def test_events_render_and_unknown_kinds_are_skipped():
@@ -64,3 +66,64 @@ 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 == []