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. spawned triage job and the dispatcher decides whether to wake the owner.
The room is a **second, independent** `Vibegram` client (`VIBEGRAM_HB`), not 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` the one the dispatcher's `vibegram` tool and the triage job share: one client
is a cursor on the hub's side: one client for both rooms would mean the triage for both rooms would mean the triage job eats the heartbeats off the shared
job eats the heartbeats, and the heartbeats drown in conversation. The model `/api/pending` cursor, and the heartbeats drown in conversation. The model
gets no tool for this room - the watchdog is a mechanism. 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 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 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 (`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. `every=unknown src=none` - lying with a number is worse than refusing.
- `сторож` (`:42` every six hours) asks for an explicit window (`pending(limit=25)`), - `сторож` (`:42` every six hours) reads a window - **the last 25 messages of
finds the newest `hb claude-kai …`, and compares its age against 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 `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, clock it trusts is `seen` - when *we* saw the line - not the `at=` inside it,
which is the neighbour's statement about himself. 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 - 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 to watch yet. Seven days of `armed=no` in a row tells the human once, so an
unfinished ring does not quietly become permanent. 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 - 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 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. forbidden. Same for a hub that did not answer at all.
+37 -6
View File
@@ -12,7 +12,13 @@
даёт честное `every=unknown`, а не подставленное число. Порог тревоги - даёт честное `every=unknown`, а не подставленное число. Порог тревоги -
`every * 1.5`, где `every` взят ИЗ СТРОКИ СОСЕДА, а не из своей константы. `every * 1.5`, где `every` взят ИЗ СТРОКИ СОСЕДА, а не из своей константы.
Каждый прогон, в котором показание снять нельзя (окно смотрит в прошлое, Каждый прогон, в котором показание снять нельзя (окно смотрит в прошлое,
период неизвестен, отметок не видели ни разу), говорит это вслух в `ack`. окно не дочитано, период неизвестен, отметок не видели ни разу), говорит
это вслух в `ack` и в лог: «в окне никого» и «окно не прочитано до конца» -
это два РАЗНЫХ показания, и выглядеть одинаково они не должны.
Окно - последние `WINDOW` СООБЩЕНИЙ этой комнаты (`Vibegram.window`), а не
«что нового с курсора»: курсор отдаёт строку соседа один раз, и следующий
прогон на живой комнате увидел бы пустое окно.
Опора тревоги - `seen`, то есть когда Я увидел строку: `at=` внутри строки Опора тревоги - `seen`, то есть когда Я увидел строку: `at=` внутри строки
это показание соседа о себе, и остановившиеся у него часы не должны это показание соседа о себе, и остановившиеся у него часы не должны
@@ -56,8 +62,8 @@ __all__ = [
PEER = "claude-kai" PEER = "claude-kai"
"""Чей `hb` сторожим. Ник соседа стоит и в его строке, и в нашем `ack`.""" """Чей `hb` сторожим. Ник соседа стоит и в его строке, и в нашем `ack`."""
WINDOW = 25 WINDOW = 25
"""Сколько событий просим у хаба явно: у `pending` свой дефолт (50), и молча """Сколько последних сообщений комнаты просим явно: молча опираться на чужой
опираться на чужой дефолт в стороже нельзя - размер окна едет в `ack`.""" дефолт в стороже нельзя - размер окна едет в `ack`."""
FACTOR = 1.5 FACTOR = 1.5
"""Порог молчания = заявленный соседом период × это. Одна потерянная строка """Порог молчания = заявленный соседом период × это. Одна потерянная строка
не должна поднимать тревогу, две подряд - должны.""" не должна поднимать тревогу, две подряд - должны."""
@@ -201,13 +207,20 @@ def ack_line(
armed: bool, armed: bool,
threshold: timedelta | None, threshold: timedelta | None,
fallback: bool, fallback: bool,
full: bool,
) -> str: ) -> str:
"""Расписка о прогоне в комнату: что видели, чем мерили и вооружены ли.""" """Расписка о прогоне в комнату: что видели, чем мерили и вооружены ли.
`win=` - про само окно: `25` это «последние 25 сообщений комнаты прочитаны
целиком», `25/partial` - «дочитать историю не удалось», `stale` - «окно
смотрит в прошлое». Пустое окно при `partial` ничего не доказывает.
"""
mark = span(threshold) + ("-fallback" if fallback and threshold else "") mark = span(threshold) + ("-fallback" if fallback and threshold else "")
read = "stale" if stale else f"{window}" if full else f"{window}/partial"
return ( return (
f"ack {peer} last={_iso(last) or ('never' if not armed else 'unknown')} " f"ack {peer} last={_iso(last) or ('never' if not armed else 'unknown')} "
f"seen={_iso(seen) or 'never'} res={span(res)} " 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}" f"thr={mark}"
) )
@@ -276,6 +289,8 @@ class Report:
ack: str ack: str
stale: bool stale: bool
full: bool
"""Окно дочитано до конца истории комнаты: пустое окно - это показание."""
armed: bool armed: bool
heard: bool heard: bool
"""Видели ли в окне хоть что-то от соседа - `hb` или его `ack`.""" """Видели ли в окне хоть что-то от соседа - `hb` или его `ack`."""
@@ -292,11 +307,16 @@ def look(
*, *,
now: datetime, now: datetime,
res: timedelta | None, res: timedelta | None,
full: bool,
window: int = WINDOW, window: int = WINDOW,
peer: str = PEER, peer: str = PEER,
) -> Report: ) -> Report:
"""Разобрать окно, обновить состояние и решить, будить ли человека. """Разобрать окно, обновить состояние и решить, будить ли человека.
`events` - последние сообщения комнаты, `full` - дочитаны ли они до конца
истории. Параметр без дефолта нарочно: «окно полное» - это показание, и
подставлять его молча за того, кто окно читал, здесь нельзя.
Состояние меняется здесь, а `alerted` / `unarmed_told` ставит джоб - и Состояние меняется здесь, а `alerted` / `unarmed_told` ставит джоб - и
только когда тревога действительно ушла: несостоявшаяся доставка не только когда тревога действительно ушла: несостоявшаяся доставка не
должна заглушать следующий прогон. должна заглушать следующий прогон.
@@ -320,7 +340,11 @@ def look(
state.every_unknown = last.every is None state.every_unknown = last.every is None
if last.every is not None: if last.every is not None:
state.every = last.every 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 armed = state.seen is not None
threshold = state.every * FACTOR if state.every is not None else 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 alarm: Literal["silence", "unarmed"] | None = None
if stale: if stale:
pass pass
elif not full:
# Хвост комнаты недочитан, а недостающее - как раз самое свежее:
# пустое окно тут не доказывает молчания соседа, оно доказывает,
# что мы не дочитали. Показания нет - тревоги нет.
note += "; окно не дочитано - это не показание, тревога не берётся"
elif not armed: elif not armed:
if ( if (
not state.unarmed_told not state.unarmed_told
@@ -360,8 +389,10 @@ def look(
armed=armed, armed=armed,
threshold=threshold, threshold=threshold,
fallback=state.every_unknown, fallback=state.every_unknown,
full=full,
), ),
stale=stale, stale=stale,
full=full,
armed=armed, armed=armed,
heard=heard, heard=heard,
age=age, age=age,
+59 -1
View File
@@ -10,6 +10,10 @@
`/api/pending` - курсор на стороне хаба: прочитанное второй раз не `/api/pending` - курсор на стороне хаба: прочитанное второй раз не
приходит. Поэтому клиент помнит последние события сам: тулза `read` приходит. Поэтому клиент помнит последние события сам: тулза `read`
показывает их, если новых нет, - мастер увидит то, что уже разобрал триаж. показывает их, если новых нет, - мастер увидит то, что уже разобрал триаж.
Там, где нужен не «что нового», а «последние N строк комнаты» (сторож),
курсор не годится в принципе: `window` читает хвост истории через
`/api/state`, не трогая курсор и ничего не вычёркивая.
""" """
from __future__ import annotations from __future__ import annotations
@@ -20,16 +24,23 @@ from dataclasses import dataclass, field
from datetime import UTC, datetime from datetime import UTC, datetime
from pathlib import Path # noqa: TC003 - в аннотации dataclass from pathlib import Path # noqa: TC003 - в аннотации dataclass
from typing import Any, Literal from typing import Any, Literal
from urllib.parse import quote
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
import aiohttp import aiohttp
__all__ = ["Action", "Event", "Vibegram"] __all__ = ["Action", "Event", "Vibegram", "Window"]
Action = Literal["read", "send", "who", "work", "plan", "claim", "release", "card"] Action = Literal["read", "send", "who", "work", "plan", "claim", "release", "card"]
USER_AGENT = "beaver-agent/0.1 (+vibegram)" USER_AGENT = "beaver-agent/0.1 (+vibegram)"
REMEMBER = 40 REMEMBER = 40
PAGE = 200
"""Сколько событий комнаты хаб отдаёт за одну страницу `/api/state`: его
потолок, а не наша просьба - `limit` там не слушают."""
PAGES = 25
"""Потолок дочитывания истории. Упёрлись - окно объявляем неполным, а не
выдаём за хвост то, что оказалось под рукой."""
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@@ -44,6 +55,16 @@ class Event:
return f"[{self.at}] {self.text}" return f"[{self.at}] {self.text}"
@dataclass(frozen=True, slots=True)
class Window:
"""Хвост комнаты: последние сообщения и честный признак полноты."""
events: list[Event]
full: bool
"""История дочитана до конца: в `events` действительно ПОСЛЕДНИЕ сообщения.
Ложь здесь - это «в окне никого» там, где окно просто не дочитано."""
@dataclass(slots=True) @dataclass(slots=True)
class Vibegram: class Vibegram:
hub: str hub: str
@@ -151,6 +172,43 @@ class Vibegram:
) )
return fresh 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: async def say(self, text: str) -> None:
"""Написать в комнату; сбой хаба - исключение, а не строка. """Написать в комнату; сбой хаба - исключение, а не строка.
+5 -3
View File
@@ -7,7 +7,8 @@
он выведен из крона ЭТОГО ЖЕ джоба (`run.job.cron`) - что зарегистрировано он выведен из крона ЭТОГО ЖЕ джоба (`run.job.cron`) - что зарегистрировано
у планировщика, то и написано в строке. Не сводится к «раз в N часов» - у планировщика, то и написано в строке. Не сводится к «раз в N часов» -
пишем `every=unknown`, потому что соврать числом хуже, чем отказаться; пишем `every=unknown`, потому что соврать числом хуже, чем отказаться;
- `watch` читает окно комнаты, сверяет возраст последней отметки соседа с - `watch` читает окно - последние `WINDOW` сообщений САМОЙ комнаты, а не
«что нового с курсора», - сверяет возраст последней отметки соседа с
порогом из ЕГО ЖЕ строки, печатает в комнату `ack` и - если цепь молчит - порогом из ЕГО ЖЕ строки, печатает в комнату `ack` и - если цепь молчит -
отдаёт тревогу мастеру через `run.spawn_job`, как это делает триаж отдаёт тревогу мастеру через `run.spawn_job`, как это делает триаж
вайбграма: будить ли владельца, решает диспетчер. вайбграма: будить ли владельца, решает диспетчер.
@@ -78,7 +79,7 @@ async def watch(run: JobRun) -> None:
if VIBEGRAM_HB is None: if VIBEGRAM_HB is None:
return return
try: try:
events = await VIBEGRAM_HB.pending(limit=WINDOW) window = await VIBEGRAM_HB.window(WINDOW)
except Exception: # noqa: BLE001 - любой отказ хаба это отсутствие показания except Exception: # noqa: BLE001 - любой отказ хаба это отсутствие показания
# Окно не прочитано - показания нет вовсе, и тревога тут запрещена # Окно не прочитано - показания нет вовсе, и тревога тут запрещена
# ровно так же, как на съехавшем окне: молчание хаба это не молчание # ровно так же, как на съехавшем окне: молчание хаба это не молчание
@@ -88,9 +89,10 @@ async def watch(run: JobRun) -> None:
state = Watch.load(STATE_FILE) state = Watch.load(STATE_FILE)
report = look( report = look(
state, state,
events, window.events,
now=datetime.now(UTC), now=datetime.now(UTC),
res=every_from_cron(run.job.cron, run.scheduler.tz), res=every_from_cron(run.job.cron, run.scheduler.tz),
full=window.full,
) )
sent = True sent = True
try: try:
+158 -21
View File
@@ -1,6 +1,8 @@
import logging
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from types import SimpleNamespace from types import SimpleNamespace
from typing import Any, cast from typing import Any, cast
from urllib.parse import parse_qsl, urlsplit
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from beaver_agent.hands.heartbeat import ( from beaver_agent.hands.heartbeat import (
@@ -14,7 +16,7 @@ from beaver_agent.hands.heartbeat import (
parse_span, parse_span,
span, span,
) )
from beaver_agent.hands.vibegram import Event from beaver_agent.hands.vibegram import PAGE, Event, Vibegram, Window
WARSAW = ZoneInfo("Europe/Warsaw") WARSAW = ZoneInfo("Europe/Warsaw")
NOW = datetime(2026, 9, 22, 15, 42, tzinfo=UTC) 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): def test_fresh_beat_arms_and_stays_quiet(tmp_path):
w = state(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.armed and report.alarm is None and not report.stale
assert report.threshold == timedelta(hours=36) assert report.threshold == timedelta(hours=36)
assert "armed=yes" in report.ack and "win=25" in report.ack 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): def test_threshold_is_every_times_one_and_a_half(tmp_path):
# 30 часов при every=24h - ещё не тревога: одна потерянная строка не в счёт # 30 часов при every=24h - ещё не тревога: одна потерянная строка не в счёт
quiet = state(tmp_path, seen=NOW - timedelta(hours=30), every=timedelta(hours=24)) 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 # 37 часов - уже за порогом 36
loud = state(tmp_path, seen=NOW - timedelta(hours=37), every=timedelta(hours=24)) 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" assert report.alarm == "silence"
# Порог берётся из строки соседа, а не из нашей константы # Порог берётся из строки соседа, а не из нашей константы
slow = state(tmp_path, seen=NOW - timedelta(hours=37), every=timedelta(hours=48)) 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): def test_last_beat_wins_over_gaps(tmp_path):
w = state(tmp_path, seen=NOW - timedelta(hours=40), every=timedelta(hours=24)) 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.alarm is None
assert report.age is not None and report.age < timedelta(hours=2) assert report.age is not None and report.age < timedelta(hours=2)
assert "res=unknown" in report.ack 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): def test_unknown_every_falls_back_to_last_known_and_says_so(tmp_path):
w = state(tmp_path, every=timedelta(hours=24)) 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 report.threshold == timedelta(hours=36)
assert "thr=36h-fallback" in report.ack assert "thr=36h-fallback" in report.ack
assert w.every == timedelta(hours=24) 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): def test_no_period_ever_means_no_measurement_and_no_alarm(tmp_path):
w = state(tmp_path, seen=NOW - timedelta(days=9)) 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 report.alarm is None and report.threshold is None
assert "thr=unknown" in report.ack assert "thr=unknown" in report.ack
assert "мерить нечем" in report.note 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): def test_never_seen_is_armed_no_not_alarm(tmp_path):
w = state(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 not report.armed and report.alarm is None
assert "armed=no" in report.ack and "last=never seen=never" in report.ack assert "armed=no" in report.ack and "last=never seen=never" in report.ack
assert w.unarmed_since == NOW 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): def test_seven_days_unarmed_tells_the_human_once(tmp_path):
w = state(tmp_path, unarmed_since=NOW - timedelta(days=7)) 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 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 assert w.unarmed_since is None
@@ -167,12 +173,16 @@ def test_stale_window_forbids_alarm(tmp_path):
w = state( w = state(
tmp_path, seen=NOW - timedelta(hours=40), every=timedelta(hours=24), top=500 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 report.stale and report.alarm is None
assert "win=stale" in report.ack assert "win=stale" in report.ack
assert w.top == 500 # съехавшее окно не двигает отметку максимума 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 assert not fresh.stale
@@ -183,14 +193,14 @@ def test_alarm_not_repeated_within_a_day(tmp_path):
every=timedelta(hours=24), every=timedelta(hours=24),
alerted=NOW - timedelta(hours=5), 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) 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): 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)) 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) silent = alarm_text(report, mine_sent=True)
assert "машина соседа" in silent and "крон" in silent and "канал" in silent assert "машина соседа" in silent and "крон" in silent and "канал" in silent
assert "канал подозревать надо раньше" in silent assert "канал подозревать надо раньше" in silent
@@ -208,6 +218,7 @@ def test_alarm_text_names_three_causes_and_suspects_the_channel(tmp_path):
], ],
now=NOW, now=NOW,
res=None, res=None,
full=True,
) )
assert heard.heard assert heard.heard
assert "канал скорее жив" in alarm_text(heard, mine_sent=True) assert "канал скорее жив" in alarm_text(heard, mine_sent=True)
@@ -234,16 +245,17 @@ def test_state_survives_a_round_trip(tmp_path):
class FakeRoom: 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.events = events
self.nick = nick self.nick = nick
self.send_fails = send_fails self.send_fails = send_fails
self.full = full
self.said = [] self.said = []
self.limit = None self.limit = None
async def pending(self, limit=50): async def window(self, limit=25):
self.limit = limit self.limit = limit
return self.events return Window(events=self.events, full=self.full)
async def say(self, text): async def say(self, text):
if self.send_fails: 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 from beaver_agent.jobs import heartbeat as job
class Dead(FakeRoom): class Dead(FakeRoom):
async def pending(self, limit=50): async def window(self, limit=25):
msg = "хаб не отвечает" msg = "хаб не отвечает"
raise RuntimeError(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)) await job.watch(cast("Any", run))
assert not run.spawned assert not run.spawned
assert not (tmp_path / "сторож.json").exists() 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 import pytest
from beaver_gateway.mcp.types import McpServer from beaver_gateway.mcp.types import McpServer
from beaver_gateway.mcp.wrap import build_python_tool_server from beaver_gateway.mcp.wrap import build_python_tool_server
from fastmcp import Client from fastmcp import Client
from fastmcp.exceptions import ToolError 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(): 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"): with pytest.raises(ToolError, match="text"):
await client.call_tool("vibegram", {"action": "send"}) 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 == []