323 lines
12 KiB
Python
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 */6 * * *"):
|
|
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=6h" 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()
|