fix(hands,jobs): watchdog window reads the room tail, not the pending cursor
This commit is contained in:
1 parent
91bd50a66d
commit
47c31ac356
6 files changed
+341
-37
No files matched your search
@@ -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.
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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:
|
||||||
"""Написать в комнату; сбой хаба - исключение, а не строка.
|
"""Написать в комнату; сбой хаба - исключение, а не строка.
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 == []
|
||||||
Reference in new issue
Block a user