Files
beaver-agent/tests/test_heartbeat.py
T

323 lines
12 KiB
Python

from datetime import UTC, datetime, timedelta
from types import SimpleNamespace
from typing import Any, cast
from zoneinfo import ZoneInfo
from beaver_agent.hands.heartbeat import (
PEER,
Watch,
alarm_text,
beat_line,
every_from_cron,
look,
parse_beat,
parse_span,
span,
)
from beaver_agent.hands.vibegram import Event
WARSAW = ZoneInfo("Europe/Warsaw")
NOW = datetime(2026, 9, 22, 15, 42, tzinfo=UTC)
def hb(at, *, every="24h", nick=PEER, eid=100):
return Event(
id=eid,
nick=nick,
kind="message",
text=(
f"{nick}: hb {nick} at={at.strftime('%Y-%m-%dT%H:%M:%SZ')} "
f"boot=2026-09-01T03:00:00Z every={every} src=cron+uptime-s"
),
at="09-22 07:00",
)
def state(tmp_path, **kw):
return Watch(path=tmp_path / "сторож.json", **kw)
def test_parse_beat_reads_fields_and_unknown_every():
beat = parse_beat(
"claude-kai: hb claude-kai at=2026-09-22T05:00:00Z "
"boot=2026-09-01T03:00:00Z every=24h src=cron+uptime-s"
)
assert beat is not None
assert beat.nick == "claude-kai"
assert beat.at == datetime(2026, 9, 22, 5, 0, tzinfo=UTC)
assert beat.boot == datetime(2026, 9, 1, 3, 0, tzinfo=UTC)
assert beat.every == timedelta(hours=24)
unknown = parse_beat(
"claude-kai: hb claude-kai at=2026-09-22T05:00:00Z "
"boot=? every=unknown src=none"
)
assert unknown is not None
assert unknown.every is None and unknown.every_raw == "unknown"
assert unknown.boot is None
assert parse_beat("claude-kai: просто сообщение") is None
assert parse_beat("claude-kai: ack beaver last=x", "ack") is not None
def test_span_round_trip():
assert span(timedelta(hours=36)) == "36h"
assert span(timedelta(minutes=90)) == "90m"
assert span(None) == "unknown"
assert parse_span("36h") == timedelta(hours=36)
assert parse_span("unknown") is None
assert parse_span("") is None
def test_every_from_cron_only_when_schedule_is_every_n_hours():
assert every_from_cron("12 17 * * *", WARSAW, now=NOW) == timedelta(hours=24)
assert every_from_cron("0 */6 * * *", WARSAW, now=NOW) == timedelta(hours=6)
# Перевод стрелок не делает суточный крон 23-часовым: считаем по стенным часам
march = datetime(2026, 3, 28, 12, tzinfo=UTC)
assert every_from_cron("12 17 * * *", WARSAW, now=march) == timedelta(hours=24)
# Не сводится к «раз в N часов» - и не сводим
assert every_from_cron("0 2,8,12,16,20 * * *", WARSAW, now=NOW) is None
assert every_from_cron("*/10 * * * *", WARSAW, now=NOW) is None
# Недельный крон тоже сводится к N часам - это честные 168h, а не unknown
assert every_from_cron("30 4 * * 0", WARSAW, now=NOW) == timedelta(hours=168)
assert every_from_cron(None, WARSAW, now=NOW) is None
assert every_from_cron("не крон", WARSAW, now=NOW) is None
def test_beat_line_says_unknown_instead_of_guessing():
line = beat_line(
nick="beaver",
at=datetime(2026, 9, 22, 15, 12, tzinfo=UTC),
boot=datetime(2026, 9, 20, 1, 2, 3, tzinfo=UTC),
every=timedelta(hours=24),
)
assert line == (
"hb beaver at=2026-09-22T15:12:00Z boot=2026-09-20T01:02:03Z every=24h src=cron"
)
blind = beat_line(nick="beaver", at=NOW, boot=NOW, every=None)
assert "every=unknown src=none" in blind
def test_fresh_beat_arms_and_stays_quiet(tmp_path):
w = state(tmp_path)
report = look(w, [hb(NOW - timedelta(hours=10))], now=NOW, res=timedelta(hours=24))
assert report.armed and report.alarm is None and not report.stale
assert report.threshold == timedelta(hours=36)
assert "armed=yes" in report.ack and "win=25" in report.ack
assert "res=24h" in report.ack and "thr=36h" in report.ack
assert w.seen == NOW and w.top == 100
def test_threshold_is_every_times_one_and_a_half(tmp_path):
# 30 часов при every=24h - ещё не тревога: одна потерянная строка не в счёт
quiet = state(tmp_path, seen=NOW - timedelta(hours=30), every=timedelta(hours=24))
assert look(quiet, [], now=NOW, res=timedelta(hours=24)).alarm is None
# 37 часов - уже за порогом 36
loud = state(tmp_path, seen=NOW - timedelta(hours=37), every=timedelta(hours=24))
report = look(loud, [], now=NOW, res=timedelta(hours=24))
assert report.alarm == "silence"
# Порог берётся из строки соседа, а не из нашей константы
slow = state(tmp_path, seen=NOW - timedelta(hours=37), every=timedelta(hours=48))
assert look(slow, [], now=NOW, res=timedelta(hours=24)).alarm is None
def test_last_beat_wins_over_gaps(tmp_path):
w = state(tmp_path, seen=NOW - timedelta(hours=40), every=timedelta(hours=24))
report = look(w, [hb(NOW - timedelta(hours=1), eid=120)], now=NOW, res=None)
assert report.alarm is None
assert report.age is not None and report.age < timedelta(hours=2)
assert "res=unknown" in report.ack
def test_unknown_every_falls_back_to_last_known_and_says_so(tmp_path):
w = state(tmp_path, every=timedelta(hours=24))
report = look(w, [hb(NOW, every="unknown", eid=130)], now=NOW, res=None)
assert report.threshold == timedelta(hours=36)
assert "thr=36h-fallback" in report.ack
assert w.every == timedelta(hours=24)
def test_no_period_ever_means_no_measurement_and_no_alarm(tmp_path):
w = state(tmp_path, seen=NOW - timedelta(days=9))
report = look(w, [], now=NOW, res=timedelta(hours=24))
assert report.alarm is None and report.threshold is None
assert "thr=unknown" in report.ack
assert "мерить нечем" in report.note
def test_never_seen_is_armed_no_not_alarm(tmp_path):
w = state(tmp_path)
report = look(w, [], now=NOW, res=timedelta(hours=24))
assert not report.armed and report.alarm is None
assert "armed=no" in report.ack and "last=never seen=never" in report.ack
assert w.unarmed_since == NOW
def test_seven_days_unarmed_tells_the_human_once(tmp_path):
w = state(tmp_path, unarmed_since=NOW - timedelta(days=7))
assert look(w, [], now=NOW, res=timedelta(hours=24)).alarm == "unarmed"
w.unarmed_told = True
assert look(w, [], now=NOW, res=timedelta(hours=24)).alarm is None
# Пришла первая отметка - кольцо вооружилось, счётчик забыт
look(w, [hb(NOW, eid=140)], now=NOW, res=timedelta(hours=24))
assert w.unarmed_since is None
def test_stale_window_forbids_alarm(tmp_path):
w = state(
tmp_path, seen=NOW - timedelta(hours=40), every=timedelta(hours=24), top=500
)
report = look(w, [hb(NOW - timedelta(hours=40), eid=7)], now=NOW, res=None)
assert report.stale and report.alarm is None
assert "win=stale" in report.ack
assert w.top == 500 # съехавшее окно не двигает отметку максимума
# Окно снова вперёд - тревога берётся
fresh = look(w, [hb(NOW - timedelta(hours=40), eid=501)], now=NOW, res=None)
assert not fresh.stale
def test_alarm_not_repeated_within_a_day(tmp_path):
w = state(
tmp_path,
seen=NOW - timedelta(hours=40),
every=timedelta(hours=24),
alerted=NOW - timedelta(hours=5),
)
assert look(w, [], now=NOW, res=None).alarm is None
w.alerted = NOW - timedelta(hours=25)
assert look(w, [], now=NOW, res=None).alarm == "silence"
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))
silent = alarm_text(report, mine_sent=True)
assert "машина соседа" in silent and "крон" in silent and "канал" in silent
assert "канал подозревать надо раньше" in silent
# Сосед хоть чем-то отметился - канал скорее жив
heard = look(
state(tmp_path, seen=NOW - timedelta(hours=40), every=timedelta(hours=24)),
[
Event(
id=9,
nick=PEER,
kind="message",
text=f"{PEER}: ack beaver last=x",
at="09-22 07:00",
)
],
now=NOW,
res=None,
)
assert heard.heard
assert "канал скорее жив" in alarm_text(heard, mine_sent=True)
def test_state_survives_a_round_trip(tmp_path):
w = state(
tmp_path,
at=NOW,
seen=NOW,
every=timedelta(hours=24),
every_unknown=True,
top=42,
alerted=NOW,
unarmed_since=None,
unarmed_told=True,
)
w.save()
back = Watch.load(w.path)
assert back.at == NOW and back.seen == NOW and back.top == 42
assert back.every == timedelta(hours=24) and back.every_unknown
assert back.alerted == NOW and back.unarmed_told
assert Watch.load(tmp_path / "нет.json").seen is None
class FakeRoom:
def __init__(self, events, *, nick="beaver", send_fails=False):
self.events = events
self.nick = nick
self.send_fails = send_fails
self.said = []
self.limit = None
async def pending(self, limit=50):
self.limit = limit
return self.events
async def say(self, text):
if self.send_fails:
msg = "хаб молчит"
raise RuntimeError(msg)
self.said.append(text)
class FakeRun:
def __init__(self, cron="42 17 * * *"):
self.job = SimpleNamespace(cron=cron, name="сторож")
self.scheduler = SimpleNamespace(tz=WARSAW)
self.spawned = []
async def spawn_job(self, *, agent, text, title=None):
self.spawned.append((agent, title, text))
return None
async def test_watch_job_prints_ack_and_spawns_alarm(tmp_path, monkeypatch):
from beaver_agent.jobs import heartbeat as job
monkeypatch.setattr(job, "STATE_FILE", tmp_path / "сторож.json")
Watch(
path=tmp_path / "сторож.json",
seen=datetime.now(UTC) - timedelta(hours=40),
every=timedelta(hours=24),
).save()
room = FakeRoom([])
monkeypatch.setattr(job, "VIBEGRAM_HB", room)
run = FakeRun()
await job.watch(cast("Any", run))
assert room.limit == 25 # окно запрошено явным размером
assert room.said and room.said[0].startswith(f"ack {PEER} ")
assert "res=24h" in room.said[0]
assert len(run.spawned) == 1
agent, title, text = run.spawned[0]
assert agent == "beaver-triage" and title == "сторож"
assert "канал подозревать надо раньше" in text
# Тревога записана в состояние - следующий прогон в те же сутки молчит
assert Watch.load(tmp_path / "сторож.json").alerted is not None
await job.watch(cast("Any", run))
assert len(run.spawned) == 1
async def test_beat_job_sends_period_from_its_own_cron(monkeypatch):
from beaver_agent.jobs import heartbeat as job
room = FakeRoom([], nick="beaver")
monkeypatch.setattr(job, "VIBEGRAM_HB", room)
await job.beat(cast("Any", FakeRun(cron="12 17 * * *")))
assert room.said[0].startswith("hb beaver at=")
assert "every=24h src=cron" in room.said[0]
odd = FakeRoom([], nick="beaver")
monkeypatch.setattr(job, "VIBEGRAM_HB", odd)
await job.beat(cast("Any", FakeRun(cron="0 2,8,12,16,20 * * *")))
assert "every=unknown src=none" in odd.said[0]
async def test_watch_job_keeps_quiet_when_the_room_is_unreadable(tmp_path, monkeypatch):
from beaver_agent.jobs import heartbeat as job
class Dead(FakeRoom):
async def pending(self, limit=50):
msg = "хаб не отвечает"
raise RuntimeError(msg)
monkeypatch.setattr(job, "STATE_FILE", tmp_path / "сторож.json")
monkeypatch.setattr(job, "VIBEGRAM_HB", Dead([]))
run = FakeRun()
await job.watch(cast("Any", run))
assert not run.spawned
assert not (tmp_path / "сторож.json").exists()