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

+37 -6
View File
@@ -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,
+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:
"""Написать в комнату; сбой хаба - исключение, а не строка.
+5 -3
View File
@@ -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: