feat(hands,jobs): drill days skip the beat and ack carries the alert decision
This commit is contained in:
1 parent
47c31ac356
commit
a39e0b27af
4 files changed
+135
-7
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user