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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
"""Написать в комнату; сбой хаба - исключение, а не строка.
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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 == []
|
||||
Reference in new issue
Block a user