diff --git a/README.md b/README.md index 14fefde..b30cc7f 100644 --- a/README.md +++ b/README.md @@ -235,11 +235,19 @@ are no defaults anywhere in it. `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. -- Every run prints `ack claude-kai last=… seen=… res=… win=… armed=… thr=…` - into the room. Anything the run could not measure says so out loud there: +- Every run prints + `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 threshold `thr=36h-fallback`; a period never read once is `thr=unknown` and 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 to watch yet. Seven days of `armed=no` in a row tells the human once, so an 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 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: ```bash diff --git a/beaver_agent/hands/heartbeat.py b/beaver_agent/hands/heartbeat.py index 631325a..bfa8a9a 100644 --- a/beaver_agent/hands/heartbeat.py +++ b/beaver_agent/hands/heartbeat.py @@ -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, diff --git a/beaver_agent/jobs/heartbeat.py b/beaver_agent/jobs/heartbeat.py index 0d102c5..101eccb 100644 --- a/beaver_agent/jobs/heartbeat.py +++ b/beaver_agent/jobs/heartbeat.py @@ -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), ) diff --git a/tests/test_heartbeat.py b/tests/test_heartbeat.py index 24753b8..52dbb25 100644 --- a/tests/test_heartbeat.py +++ b/tests/test_heartbeat.py @@ -10,6 +10,7 @@ from beaver_agent.hands.heartbeat import ( Watch, alarm_text, beat_line, + drill, every_from_cron, look, 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" +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): w = state(tmp_path, seen=NOW - timedelta(hours=40), every=timedelta(hours=24)) 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] +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): from beaver_agent.jobs import heartbeat as job