feat(hands,jobs): drill days skip the beat and ack carries the alert decision

This commit is contained in:
hh committed 2026-09-26 01:52:53 +00:00
1 parent 47c31ac356
commit a39e0b27af
4 files changed
+135 -7

No files matched your search

+31 -2
View File
@@ -54,6 +54,7 @@ __all__ = [
"ack_line",
"alarm_text",
"beat_line",
"drill",
"every_from_cron",
"look",
"parse_beat",
@@ -208,12 +209,21 @@ def ack_line(
threshold: timedelta | None,
fallback: bool,
full: bool,
alert: Literal["fired", "held", "none"],
) -> str:
"""Расписка о прогоне в комнату: что видели, чем мерили и вооружены ли.
"""Расписка о прогоне в комнату: что видели, чем мерили и что решили.
`win=` - про само окно: `25` это «последние 25 сообщений комнаты прочитаны
целиком», `25/partial` - «дочитать историю не удалось», `stale` - «окно
смотрит в прошлое». Пустое окно при `partial` ничего не доказывает.
`alert=` - РЕШЕНИЕ ПРИБОРА в этом прогоне, а не факт того, что человек
сосед что-то получил: `fired` - тревога (`silence` или `unarmed`) в этом
прогоне взята и отдана дальше, `held` - порог пройден, но тревога
подавлена как повтор (предыдущая уходила меньше `REPEAT` назад), `none` -
тревоги нет. Доставка решается за пределами сторожа - мастером и
диспетчером, - и её отказ виден в логе, а не тут: без арифметики соседу
видно было только, что мы что-то посчитали, но не что мы из этого поняли.
"""
mark = span(threshold) + ("-fallback" if fallback and threshold else "")
read = "stale" if stale else f"{window}" if full else f"{window}/partial"
@@ -221,10 +231,26 @@ def ack_line(
f"ack {peer} last={_iso(last) or ('never' if not armed else 'unknown')} "
f"seen={_iso(seen) or 'never'} res={span(res)} "
f"win={read} armed={'yes' if armed else 'no'} "
f"thr={mark}"
f"thr={mark} alert={alert}"
)
def drill(path: Path, *, now: datetime, tz: tzinfo) -> bool:
"""Стоит ли сегодняшняя дата в файле флага учений.
Файл - список дат `YYYY-MM-DD`, по одной на строку; дата - локальная дата
прогона в зоне планировщика, той же, в которой читаются кроны. Файла нет,
он пуст или нечитаем - учений нет, и отметка уходит как обычно: флаг
выключает прибор, поэтому его отсутствие должно значить «работаем».
"""
try:
raw = path.read_text(encoding="utf-8")
except OSError:
return False
today = now.astimezone(tz).strftime("%Y-%m-%d")
return today in {line.strip() for line in raw.splitlines()}
@dataclass(slots=True)
class Watch:
"""Состояние между прогонами: файл на диске, переживает передеплой."""
@@ -356,6 +382,7 @@ def look(
state.unarmed_since = now
alarm: Literal["silence", "unarmed"] | None = None
held = False
if stale:
pass
elif not full:
@@ -376,6 +403,7 @@ def look(
if state.alerted is None or now - state.alerted >= REPEAT:
alarm = "silence"
else:
held = True
note += "; тревога уже уходила меньше суток назад"
return Report(
@@ -390,6 +418,7 @@ def look(
threshold=threshold,
fallback=state.every_unknown,
full=full,
alert="fired" if alarm is not None else "held" if held else "none",
),
stale=stale,
full=full,
+25 -3
View File
@@ -6,7 +6,9 @@
- `beat` печатает `hb <ник> at=… boot=… every=… src=…`. Период не константа:
он выведен из крона ЭТОГО ЖЕ джоба (`run.job.cron`) - что зарегистрировано
у планировщика, то и написано в строке. Не сводится к «раз в N часов» -
пишем `every=unknown`, потому что соврать числом хуже, чем отказаться;
пишем `every=unknown`, потому что соврать числом хуже, чем отказаться.
В день, помеченный в `DRILL_FILE`, отметка не уходит: так проверяют, что
тревога на той стороне жива, а не только что она молчит на живой цепи;
- `watch` читает окно - последние `WINDOW` сообщений САМОЙ комнаты, а не
«что нового с курсора», - сверяет возраст последней отметки соседа с
порогом из ЕГО ЖЕ строки, печатает в комнату `ack` и - если цепь молчит -
@@ -32,6 +34,7 @@ from beaver_agent.hands.heartbeat import (
Watch,
alarm_text,
beat_line,
drill,
every_from_cron,
look,
)
@@ -46,6 +49,12 @@ STATE_FILE = STATE_DIR / "сторож.json"
"""Что видели в прошлый прогон: без файла сторож после передеплоя считает,
что соседа не видел ни разу, и разоружается сам по себе."""
DRILL_FILE = STATE_DIR / "сторож-учения.txt"
"""Даты учений, по одной `YYYY-MM-DD` на строку, локальные в зоне планировщика.
В такой день `beat` молчит в комнате - в этом и учения: проверяем, что тревога
у соседа действительно срабатывает. Файл, а не константа и не переменная среды:
даты меняются от учений к учениям, а передеплой ради них не нужен."""
TITLE = "сторож"
BRIEF = (
@@ -61,12 +70,25 @@ BRIEF = (
async def beat(run: JobRun) -> None:
"""Отметка о том, что мы живы. Не ушла - джоб падает, это видно в прогонах."""
"""Отметка о том, что мы живы. Не ушла - джоб падает, это видно в прогонах.
В день из `DRILL_FILE` отметка не уходит вовсе: учения - это МОЛЧАНИЕ в
комнате, и любой текст туда («сегодня пропускаем») их бы и отменил. Видно
такой прогон только в логе.
"""
if VIBEGRAM_HB is None:
return
now = datetime.now(UTC)
if drill(DRILL_FILE, now=now, tz=run.scheduler.tz):
_log.info(
"сторож: отметка пропущена по флагу учений %s (%s)",
DRILL_FILE,
now.astimezone(run.scheduler.tz).strftime("%Y-%m-%d"),
)
return
line = beat_line(
nick=VIBEGRAM_HB.nick,
at=datetime.now(UTC),
at=now,
boot=BOOT,
every=every_from_cron(run.job.cron, run.scheduler.tz),
)