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

+19 -2
View File
@@ -235,11 +235,19 @@ are no defaults anywhere in it.
`every * 1.5` where `every` comes **from the neighbour's own line**. The `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, clock it trusts is `seen` - when *we* saw the line - not the `at=` inside it,
which is the neighbour's statement about himself. which is the neighbour's statement about himself.
- Every run prints `ack claude-kai last=… seen=… res=… win=… armed=… thr=…` - Every run prints
into the room. Anything the run could not measure says so out loud there: `ack claude-kai last=… seen=… res=… win=… armed=… thr=… alert=…` into the
room. Anything the run could not measure says so out loud there:
`every=unknown` in his line falls back to the last known period and marks the `every=unknown` in his line falls back to the last known period and marks the
threshold `thr=36h-fallback`; a period never read once is `thr=unknown` and threshold `thr=36h-fallback`; a period never read once is `thr=unknown` and
no alarm is taken. no alarm is taken.
- `alert=` is what the **instrument decided** in that run, not what reached a
human: `fired` - an alarm (`silence` or `unarmed`) was taken and handed on,
`held` - the threshold was crossed but the alarm was suppressed as a repeat
(the previous one went out less than a day ago), `none` - no alarm. Without
it the neighbour saw the arithmetic and had to guess what we made of it.
Delivery is decided past the watchdog, by the master thread and the
dispatcher, and its failure shows up in the log, not here.
- Never having seen a beat is not an alarm, it is `armed=no` - there is nothing - 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 to watch yet. Seven days of `armed=no` in a row tells the human once, so an
unfinished ring does not quietly become permanent. unfinished ring does not quietly become permanent.
@@ -267,6 +275,15 @@ are no defaults anywhere in it.
State between runs is `мета/бобер/.состояние/сторож.json`; a lost file means State between runs is `мета/бобер/.состояние/сторож.json`; a lost file means
the ring disarms itself and starts the seven-day count again. the ring disarms itself and starts the seven-day count again.
**Drills.** Next to that state file lives `мета/бобер/.состояние/сторож-учения.txt`
- a list of drill dates, one `YYYY-MM-DD` per line, each the local date of the
run in the scheduler's zone (`Europe/Warsaw`). On a date in that list `отметка`
sends nothing into the room and says so in the log only: a skipped beat is the
drill, and any text there ("skipping today") would call it off. No file, an
empty one or an unreadable one means no drill and the beat goes out as usual.
The dates are not in the code, and adding one needs no redeploy - a ring only
ever tested on "all alive" has never shown that the other side's alarm fires.
Setup is two variables in `.env` and one manual step: Setup is two variables in `.env` and one manual step:
```bash ```bash
+31 -2
View File
@@ -54,6 +54,7 @@ __all__ = [
"ack_line", "ack_line",
"alarm_text", "alarm_text",
"beat_line", "beat_line",
"drill",
"every_from_cron", "every_from_cron",
"look", "look",
"parse_beat", "parse_beat",
@@ -208,12 +209,21 @@ def ack_line(
threshold: timedelta | None, threshold: timedelta | None,
fallback: bool, fallback: bool,
full: bool, full: bool,
alert: Literal["fired", "held", "none"],
) -> str: ) -> str:
"""Расписка о прогоне в комнату: что видели, чем мерили и вооружены ли. """Расписка о прогоне в комнату: что видели, чем мерили и что решили.
`win=` - про само окно: `25` это «последние 25 сообщений комнаты прочитаны `win=` - про само окно: `25` это «последние 25 сообщений комнаты прочитаны
целиком», `25/partial` - «дочитать историю не удалось», `stale` - «окно целиком», `25/partial` - «дочитать историю не удалось», `stale` - «окно
смотрит в прошлое». Пустое окно при `partial` ничего не доказывает. смотрит в прошлое». Пустое окно при `partial` ничего не доказывает.
`alert=` - РЕШЕНИЕ ПРИБОРА в этом прогоне, а не факт того, что человек
сосед что-то получил: `fired` - тревога (`silence` или `unarmed`) в этом
прогоне взята и отдана дальше, `held` - порог пройден, но тревога
подавлена как повтор (предыдущая уходила меньше `REPEAT` назад), `none` -
тревоги нет. Доставка решается за пределами сторожа - мастером и
диспетчером, - и её отказ виден в логе, а не тут: без арифметики соседу
видно было только, что мы что-то посчитали, но не что мы из этого поняли.
""" """
mark = span(threshold) + ("-fallback" if fallback and threshold else "") mark = span(threshold) + ("-fallback" if fallback and threshold else "")
read = "stale" if stale else f"{window}" if full else f"{window}/partial" 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"ack {peer} last={_iso(last) or ('never' if not armed else 'unknown')} "
f"seen={_iso(seen) or 'never'} res={span(res)} " f"seen={_iso(seen) or 'never'} res={span(res)} "
f"win={read} armed={'yes' if armed else 'no'} " 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) @dataclass(slots=True)
class Watch: class Watch:
"""Состояние между прогонами: файл на диске, переживает передеплой.""" """Состояние между прогонами: файл на диске, переживает передеплой."""
@@ -356,6 +382,7 @@ def look(
state.unarmed_since = now state.unarmed_since = now
alarm: Literal["silence", "unarmed"] | None = None alarm: Literal["silence", "unarmed"] | None = None
held = False
if stale: if stale:
pass pass
elif not full: elif not full:
@@ -376,6 +403,7 @@ def look(
if state.alerted is None or now - state.alerted >= REPEAT: if state.alerted is None or now - state.alerted >= REPEAT:
alarm = "silence" alarm = "silence"
else: else:
held = True
note += "; тревога уже уходила меньше суток назад" note += "; тревога уже уходила меньше суток назад"
return Report( return Report(
@@ -390,6 +418,7 @@ def look(
threshold=threshold, threshold=threshold,
fallback=state.every_unknown, fallback=state.every_unknown,
full=full, full=full,
alert="fired" if alarm is not None else "held" if held else "none",
), ),
stale=stale, stale=stale,
full=full, full=full,
+25 -3
View File
@@ -6,7 +6,9 @@
- `beat` печатает `hb <ник> at=… boot=… every=… src=…`. Период не константа: - `beat` печатает `hb <ник> at=… boot=… every=… src=…`. Период не константа:
он выведен из крона ЭТОГО ЖЕ джоба (`run.job.cron`) - что зарегистрировано он выведен из крона ЭТОГО ЖЕ джоба (`run.job.cron`) - что зарегистрировано
у планировщика, то и написано в строке. Не сводится к «раз в N часов» - у планировщика, то и написано в строке. Не сводится к «раз в N часов» -
пишем `every=unknown`, потому что соврать числом хуже, чем отказаться; пишем `every=unknown`, потому что соврать числом хуже, чем отказаться.
В день, помеченный в `DRILL_FILE`, отметка не уходит: так проверяют, что
тревога на той стороне жива, а не только что она молчит на живой цепи;
- `watch` читает окно - последние `WINDOW` сообщений САМОЙ комнаты, а не - `watch` читает окно - последние `WINDOW` сообщений САМОЙ комнаты, а не
«что нового с курсора», - сверяет возраст последней отметки соседа с «что нового с курсора», - сверяет возраст последней отметки соседа с
порогом из ЕГО ЖЕ строки, печатает в комнату `ack` и - если цепь молчит - порогом из ЕГО ЖЕ строки, печатает в комнату `ack` и - если цепь молчит -
@@ -32,6 +34,7 @@ from beaver_agent.hands.heartbeat import (
Watch, Watch,
alarm_text, alarm_text,
beat_line, beat_line,
drill,
every_from_cron, every_from_cron,
look, look,
) )
@@ -46,6 +49,12 @@ STATE_FILE = STATE_DIR / "сторож.json"
"""Что видели в прошлый прогон: без файла сторож после передеплоя считает, """Что видели в прошлый прогон: без файла сторож после передеплоя считает,
что соседа не видел ни разу, и разоружается сам по себе.""" что соседа не видел ни разу, и разоружается сам по себе."""
DRILL_FILE = STATE_DIR / "сторож-учения.txt"
"""Даты учений, по одной `YYYY-MM-DD` на строку, локальные в зоне планировщика.
В такой день `beat` молчит в комнате - в этом и учения: проверяем, что тревога
у соседа действительно срабатывает. Файл, а не константа и не переменная среды:
даты меняются от учений к учениям, а передеплой ради них не нужен."""
TITLE = "сторож" TITLE = "сторож"
BRIEF = ( BRIEF = (
@@ -61,12 +70,25 @@ BRIEF = (
async def beat(run: JobRun) -> None: async def beat(run: JobRun) -> None:
"""Отметка о том, что мы живы. Не ушла - джоб падает, это видно в прогонах.""" """Отметка о том, что мы живы. Не ушла - джоб падает, это видно в прогонах.
В день из `DRILL_FILE` отметка не уходит вовсе: учения - это МОЛЧАНИЕ в
комнате, и любой текст туда («сегодня пропускаем») их бы и отменил. Видно
такой прогон только в логе.
"""
if VIBEGRAM_HB is None: if VIBEGRAM_HB is None:
return 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( line = beat_line(
nick=VIBEGRAM_HB.nick, nick=VIBEGRAM_HB.nick,
at=datetime.now(UTC), at=now,
boot=BOOT, boot=BOOT,
every=every_from_cron(run.job.cron, run.scheduler.tz), every=every_from_cron(run.job.cron, run.scheduler.tz),
) )
+60
View File
@@ -10,6 +10,7 @@ from beaver_agent.hands.heartbeat import (
Watch, Watch,
alarm_text, alarm_text,
beat_line, beat_line,
drill,
every_from_cron, every_from_cron,
look, look,
parse_beat, parse_beat,
@@ -198,6 +199,39 @@ def test_alarm_not_repeated_within_a_day(tmp_path):
assert look(w, [], now=NOW, res=None, full=True).alarm == "silence" assert look(w, [], now=NOW, res=None, full=True).alarm == "silence"
def test_ack_says_what_the_instrument_decided(tmp_path):
# Цепь жива - решать нечего
alive = state(tmp_path)
assert "alert=none" in look(alive, [hb(NOW)], now=NOW, res=None, full=True).ack
# Порог пройден, тревога взята в этом прогоне
loud = state(tmp_path, seen=NOW - timedelta(hours=40), every=timedelta(hours=24))
fired = look(loud, [], now=NOW, res=None, full=True)
assert fired.alarm == "silence" and "alert=fired" in fired.ack
# Порог пройден, но тревога уходила меньше суток назад - повтор подавлен
loud.alerted = NOW - timedelta(hours=5)
held = look(loud, [], now=NOW, res=None, full=True)
assert held.alarm is None and "alert=held" in held.ack
# Кольцо так и не вооружилось дольше недели - это тоже выстрел
late = state(tmp_path, unarmed_since=NOW - timedelta(days=8))
assert "alert=fired" in look(late, [], now=NOW, res=None, full=True).ack
# Показания нет (окно не дочитано) - не `held`, а `none`: подавлять нечего
unread = state(tmp_path, seen=NOW - timedelta(hours=40), every=timedelta(hours=24))
assert "alert=none" in look(unread, [], now=NOW, res=None, full=False).ack
def test_drill_flag_is_a_list_of_local_dates(tmp_path):
flag = tmp_path / "сторож-учения.txt"
assert not drill(flag, now=NOW, tz=WARSAW) # файла нет - отметка уходит
flag.write_text("\n \n", encoding="utf-8")
assert not drill(flag, now=NOW, tz=WARSAW) # пустой - тоже
flag.write_text("2026-09-28\n2026-09-29\n", encoding="utf-8")
assert drill(flag, now=datetime(2026, 9, 28, 15, 12, tzinfo=UTC), tz=WARSAW)
assert not drill(flag, now=datetime(2026, 9, 27, 15, 12, tzinfo=UTC), tz=WARSAW)
# Дата локальная в зоне планировщика: 22:30 UTC - это уже 29-е в Варшаве
assert drill(flag, now=datetime(2026, 9, 28, 22, 30, tzinfo=UTC), tz=WARSAW)
assert not drill(flag, now=datetime(2026, 9, 29, 22, 30, tzinfo=UTC), tz=WARSAW)
def test_alarm_text_names_three_causes_and_suspects_the_channel(tmp_path): 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)) w = state(tmp_path, seen=NOW - timedelta(hours=40), every=timedelta(hours=24))
report = look(w, [], now=NOW, res=timedelta(hours=24), full=True) report = look(w, [], now=NOW, res=timedelta(hours=24), full=True)
@@ -318,6 +352,32 @@ async def test_beat_job_sends_period_from_its_own_cron(monkeypatch):
assert "every=unknown src=none" in odd.said[0] assert "every=unknown src=none" in odd.said[0]
async def test_beat_job_keeps_silent_on_a_drill_day(tmp_path, monkeypatch, caplog):
from beaver_agent.jobs import heartbeat as job
flag = tmp_path / "сторож-учения.txt"
monkeypatch.setattr(job, "DRILL_FILE", flag)
room = FakeRoom([], nick="beaver")
monkeypatch.setattr(job, "VIBEGRAM_HB", room)
today = datetime.now(WARSAW).strftime("%Y-%m-%d")
flag.write_text(f"2026-01-01\n{today}\n", encoding="utf-8")
with caplog.at_level(logging.INFO):
await job.beat(cast("Any", FakeRun(cron="12 17 * * *")))
assert not room.said # молчание в комнате и есть учения
assert "пропущена по флагу" in caplog.text
# Не наш день - отметка уходит как обычно
flag.write_text("2026-01-01\n", encoding="utf-8")
await job.beat(cast("Any", FakeRun(cron="12 17 * * *")))
assert len(room.said) == 1 and room.said[0].startswith("hb beaver at=")
# Файла нет - тоже обычный прогон
flag.unlink()
await job.beat(cast("Any", FakeRun(cron="12 17 * * *")))
assert len(room.said) == 2
async def test_watch_job_keeps_quiet_when_the_room_is_unreadable(tmp_path, monkeypatch): async def test_watch_job_keeps_quiet_when_the_room_is_unreadable(tmp_path, monkeypatch):
from beaver_agent.jobs import heartbeat as job from beaver_agent.jobs import heartbeat as job