diff --git a/README.md b/README.md index 7b0a7e3..14fefde 100644 --- a/README.md +++ b/README.md @@ -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=`; 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. diff --git a/beaver_agent/hands/heartbeat.py b/beaver_agent/hands/heartbeat.py index 9904475..631325a 100644 --- a/beaver_agent/hands/heartbeat.py +++ b/beaver_agent/hands/heartbeat.py @@ -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, diff --git a/beaver_agent/hands/vibegram.py b/beaver_agent/hands/vibegram.py index 354ab2b..c1e048f 100644 --- a/beaver_agent/hands/vibegram.py +++ b/beaver_agent/hands/vibegram.py @@ -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=`, - + назад хаб не пагинирует и `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: """Написать в комнату; сбой хаба - исключение, а не строка. diff --git a/beaver_agent/jobs/heartbeat.py b/beaver_agent/jobs/heartbeat.py index 09b4fd7..0d102c5 100644 --- a/beaver_agent/jobs/heartbeat.py +++ b/beaver_agent/jobs/heartbeat.py @@ -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: diff --git a/tests/test_heartbeat.py b/tests/test_heartbeat.py index b476cea..24753b8 100644 --- a/tests/test_heartbeat.py +++ b/tests/test_heartbeat.py @@ -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=` и отдаётся + страницами вперёд; `/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 diff --git a/tests/test_vibegram.py b/tests/test_vibegram.py index 2245068..d24ce12 100644 --- a/tests/test_vibegram.py +++ b/tests/test_vibegram.py @@ -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 == []