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

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

No files matched your search

+59 -1
View File
@@ -10,6 +10,10 @@
`/api/pending` - курсор на стороне хаба: прочитанное второй раз не
приходит. Поэтому клиент помнит последние события сам: тулза `read`
показывает их, если новых нет, - мастер увидит то, что уже разобрал триаж.
Там, где нужен не «что нового», а «последние N строк комнаты» (сторож),
курсор не годится в принципе: `window` читает хвост истории через
`/api/state`, не трогая курсор и ничего не вычёркивая.
"""
from __future__ import annotations
@@ -20,16 +24,23 @@ from dataclasses import dataclass, field
from datetime import UTC, datetime
from pathlib import Path # noqa: TC003 - в аннотации dataclass
from typing import Any, Literal
from urllib.parse import quote
from zoneinfo import ZoneInfo
import aiohttp
__all__ = ["Action", "Event", "Vibegram"]
__all__ = ["Action", "Event", "Vibegram", "Window"]
Action = Literal["read", "send", "who", "work", "plan", "claim", "release", "card"]
USER_AGENT = "beaver-agent/0.1 (+vibegram)"
REMEMBER = 40
PAGE = 200
"""Сколько событий комнаты хаб отдаёт за одну страницу `/api/state`: его
потолок, а не наша просьба - `limit` там не слушают."""
PAGES = 25
"""Потолок дочитывания истории. Упёрлись - окно объявляем неполным, а не
выдаём за хвост то, что оказалось под рукой."""
@dataclass(frozen=True, slots=True)
@@ -44,6 +55,16 @@ class Event:
return f"[{self.at}] {self.text}"
@dataclass(frozen=True, slots=True)
class Window:
"""Хвост комнаты: последние сообщения и честный признак полноты."""
events: list[Event]
full: bool
"""История дочитана до конца: в `events` действительно ПОСЛЕДНИЕ сообщения.
Ложь здесь - это «в окне никого» там, где окно просто не дочитано."""
@dataclass(slots=True)
class Vibegram:
hub: str
@@ -151,6 +172,43 @@ class Vibegram:
)
return fresh
async def window(self, limit: int, *, pages: int = PAGES) -> Window:
"""Последние `limit` СООБЩЕНИЙ этой комнаты: хвост истории, не курсор.
`pending` для окна не годится вовсе: это курсор на стороне хаба, он
отдаёт только то, чего мы ещё не видели, и ту же строку второй раз не
пришлёт - следующий прогон увидит пустое окно на живой комнате.
Хвост берём из `/api/state?view=…`: события там уже отфильтрованы по
комнате (ключ - `viewToken` этой комнаты, id у хаба сквозные по всем
комнатам), но страницы идут ВПЕРЁД, по `PAGE` штук от `after=<id>`, -
назад хаб не пагинирует и `limit` не слушает. Поэтому проходим историю
от начала, держа в руке только последние `limit` сообщений. `join` и
`leave` в окно не берём: это бухгалтерия хаба, а не строки соседа.
`viewToken` спрашиваем каждый раз заново: протухший в кэше ключ - ровно
то тихое враньё, ради которого окно и переписано.
"""
view = quote(str((await self._get("/api/room"))["room"]["viewToken"]))
tail: deque[Event] = deque(maxlen=limit)
after = 0
full = False
for _ in range(pages):
page = await self._get(f"/api/state?view={view}&after={after}")
raw = page.get("events") or []
for item in raw:
if item.get("kind") != "message":
continue
event = _event(item, self.tz)
if event is not None:
tail.append(event)
top = max((int(item.get("id") or 0) for item in raw), default=after)
if len(raw) < PAGE or top <= after:
full = True
break
after = top
return Window(events=list(tail), full=full)
async def say(self, text: str) -> None:
"""Написать в комнату; сбой хаба - исключение, а не строка.