feat(hands,jobs): job-run watchdog looks a week back and alarms on jobs that stopped running

This commit is contained in:
hh committed 2026-09-22 20:15:15 +02:00
1 parent 426b4055f4
commit 91bd50a66d
6 files changed
+620 -71

No files matched your search

+230 -4
View File
@@ -9,11 +9,13 @@ from beaver_agent.hands.runs import (
Run,
Runs,
alarm_text,
awake,
clear_text,
empty_text,
first_line,
look,
mask,
quiet_text,
)
NOW = datetime(2026, 9, 22, 15, 42, tzinfo=UTC)
@@ -126,7 +128,7 @@ def test_streak_running_past_the_window_says_at_least(tmp_path):
assert tail.capped and tail.count == 144
text = alarm_text(report)
assert "лежит 24 часа и дольше, 144 прогона подряд" in text
assert "это всё окно суток, серия началась раньше" in text
assert "это всё окно недели, серия началась раньше" in text
def test_empty_window_is_its_own_alarm(tmp_path):
@@ -210,9 +212,12 @@ def test_state_survives_a_round_trip(tmp_path):
class FakeRun:
def __init__(self, database=None, *, name="прогоны"):
def __init__(self, database=None, *, name="прогоны", jobs=()):
self.job = SimpleNamespace(name=name, cron="27 * * * *")
self.conversations = SimpleNamespace(db=database)
self.scheduler = SimpleNamespace(
jobs=[self.job, *(SimpleNamespace(name=n, cron=c) for n, c in jobs)], tz=UTC
)
self.spawned = []
async def spawn_job(self, *, agent, text, title=None):
@@ -259,7 +264,7 @@ async def test_job_reads_the_real_journal_and_spawns(tmp_path, monkeypatch):
record("вайбграм", "failed", ago=10),
record("досуг", "done", ago=5),
record("прогоны", "done", ago=3),
# Старше суток - в окно не попадает
# Одно падение - не серия, сколько бы окно ни было
record("логи", "failed", ago=60 * 30),
],
)
@@ -271,7 +276,7 @@ async def test_job_reads_the_real_journal_and_spawns(tmp_path, monkeypatch):
agent, title, text = run_.spawned[0]
assert agent == "beaver-triage" and title == "прогоны"
assert "джоб `вайбграм` лежит" in text and "3 прогона подряд" in text
assert "логи" not in text # за окном
assert "логи" not in text # одно падение
assert "прогоны" not in text.split("\n")[1] # сам себя не считает
# Тревога записана - следующий прогон в те же сутки молчит
assert Runs.load(tmp_path / "прогоны.json").alerted["вайбграм"] is not None
@@ -364,3 +369,224 @@ async def test_the_watcher_never_reports_itself(tmp_path, monkeypatch, name):
assert not run_.spawned
await database.dispose()
# --- Окно неделя: суточные джобы тоже набирают серию ---------------------------
def test_daily_job_failing_three_days_in_a_row_is_an_alarm(tmp_path):
day = 24 * 60
rows = [
run(1, "закрытие", "done", ago=4 * day - 60),
run(2, "закрытие", "failed", ago=3 * day - 60),
run(3, "закрытие", "failed", ago=2 * day - 60),
run(4, "закрытие", "failed", ago=day - 60),
]
report = look(state(tmp_path), rows, now=NOW, me="прогоны")
(tail,) = report.alarms
assert tail.job == "закрытие" and tail.count == 3 and not tail.capped
assert "джоб `закрытие` лежит 2 суток, 3 прогона подряд" in alarm_text(report)
async def test_job_reads_a_week_back(tmp_path, monkeypatch):
from beaver_agent.jobs import runs as job
monkeypatch.setattr(job, "STATE_FILE", tmp_path / "прогоны.json")
day = 24 * 60
database = await make_db(
tmp_path,
[
record("голосовой", "failed", ago=3 * day - 30),
record("голосовой", "failed", ago=2 * day - 30),
record("голосовой", "failed", ago=day - 30),
# Старше недели - в окно не попадает
record("память", "failed", ago=9 * day),
],
)
run_ = FakeRun(database)
await job.watch_runs(cast("Any", run_))
(spawned,) = run_.spawned
assert "джоб `голосовой` лежит" in spawned[2]
assert "память" not in spawned[2]
await database.dispose()
# --- Давность: джоб перестал запускаться ---------------------------------------
CRONS = {"прогоны": "27 * * * *", "закрытие": "20 4 * * *", "вайбграм": "*/10 * * * *"}
def hourly_me(hours=72):
"""Непрерывная цепочка своих прогонов: планировщик жив всё это время."""
return [
run(5000 + i, "прогоны", "done", ago=60 * (hours - i) - 15)
for i in range(hours)
]
def alive(ago=5):
return run(9000, "вайбграм", "done", ago=ago)
def silent(tmp_path, *, closing_ago_h, mine=None, boot=None, **kw):
last = run(1, "закрытие", "done", ago=closing_ago_h * 60)
return look(
kw.pop("runs", None) or state(tmp_path),
[alive(), *(hourly_me() if mine is None else mine)],
now=NOW,
me="прогоны",
crons=kw.pop("crons", CRONS),
latest=kw.pop("latest", {"закрытие": last, "вайбграм": alive()}),
tz=UTC,
boot=boot,
)
def test_job_silent_longer_than_two_periods_is_its_own_alarm(tmp_path):
report = silent(tmp_path, closing_ago_h=50)
(quiet,) = report.quiet
assert quiet.job == "закрытие" and quiet.period == timedelta(hours=24)
assert not report.alarms # это не «лежит»
text = quiet_text(report)
assert "джоб `закрытие` не запускался 2 суток при периоде 24 часа" in text
assert "лежит" not in text
def test_one_missed_fire_is_not_silence(tmp_path):
# 40 часов: пропущено одно срабатывание, не два
assert not silent(tmp_path, closing_ago_h=40).quiet
def test_silence_is_told_once_a_day_and_cleared_by_a_run(tmp_path):
told = state(tmp_path, quiet={"закрытие": NOW - timedelta(hours=5)})
held = silent(tmp_path, closing_ago_h=50, runs=told)
assert not held.quiet
assert "о молчании уже говорили меньше суток назад: закрытие" in held.note
older = state(tmp_path, quiet={"закрытие": NOW - timedelta(hours=25)})
assert silent(tmp_path, closing_ago_h=50, runs=older).quiet
# Запуск появился после тревоги - «отбой», а не молчание
back = silent(tmp_path, closing_ago_h=1, runs=told)
(resumed,) = back.resumed
assert resumed.job == "закрытие" and not back.clears
assert "отбой: джоб `закрытие` снова запускается (done)" in clear_text(back)
def test_downtime_of_the_scheduler_is_not_a_missed_fire(tmp_path):
# Гейтвей только что поднялся: после старта срабатывания ещё не было
fresh = silent(tmp_path, closing_ago_h=50, boot=NOW - timedelta(minutes=30))
assert not fresh.quiet
# Сторож сам не бегал сутки (бюджет зажат, некритичное откладывается):
# пропуски за это время - общий простой, а не молчание одного джоба
gap = [run(1, "прогоны", "done", ago=60 * 30)]
assert not silent(tmp_path, closing_ago_h=50, mine=gap).quiet
assert awake(gap, now=NOW, step=timedelta(hours=2), boot=None) == NOW
def test_short_period_job_uses_the_watchers_resolution(tmp_path):
latest = {"закрытие": run(1, "закрытие", "done", ago=60)}
# Вайбграм раз в 10 минут, молчит 70 минут: сторож часовой, и такой
# пропуск неотличим от его же слепоты между прогонами
latest["вайбграм"] = run(2, "вайбграм", "done", ago=70)
assert not silent(tmp_path, closing_ago_h=1, latest=latest).quiet
latest["вайбграм"] = run(2, "вайбграм", "done", ago=150)
(quiet,) = silent(tmp_path, closing_ago_h=1, latest=latest).quiet
assert quiet.job == "вайбграм"
assert "не запускался 2 часа при периоде 10 минут" in quiet_text(
silent(tmp_path, closing_ago_h=1, latest=latest)
)
def test_job_without_cron_is_not_checked(tmp_path):
# deploy - вебхук: крона у него нет, в `crons` он не попадает вовсе
latest = {
"deploy": run(1, "deploy", "done", ago=60 * 24 * 30),
"закрытие": run(2, "закрытие", "done", ago=60),
}
report = silent(tmp_path, closing_ago_h=1, latest=latest)
assert not report.quiet and "deploy" not in report.note
def test_job_never_seen_in_the_journal_waits_silently(tmp_path):
report = silent(tmp_path, closing_ago_h=0, latest={"вайбграм": alive()})
assert not report.quiet and not report.resumed
assert "ещё ни разу не запускались: закрытие" in report.note
def test_irregular_schedule_is_skipped_and_named(tmp_path):
crons = {**CRONS, "куратор": "0 2,8,12,16,20 * * *"}
report = silent(tmp_path, closing_ago_h=1, crons=crons, latest={})
assert report.unguarded == ("куратор",)
assert "давностью не охраняются" in report.note and "куратор" in report.note
assert not report.quiet
def test_the_watcher_does_not_measure_its_own_silence(tmp_path):
latest = {
"прогоны": run(1, "прогоны", "done", ago=60 * 24 * 5),
"закрытие": run(2, "закрытие", "done", ago=60),
}
report = silent(tmp_path, closing_ago_h=1, latest=latest)
assert not report.quiet
def test_job_removed_from_the_schedule_leaves_silence_state(tmp_path):
told = state(tmp_path, quiet={"старый": NOW - timedelta(hours=3)})
report = silent(tmp_path, closing_ago_h=1, runs=told)
assert not report.resumed and "старый" not in told.quiet
assert "сняты с давности (нет в расписании): старый" in report.note
def test_real_schedule_names_only_the_curator_unguarded(tmp_path):
from beaver_agent.jobs import jobs
crons = {job.name: job.cron for job in jobs if job.cron}
report = silent(tmp_path, closing_ago_h=1, crons=crons, latest={})
assert report.unguarded == ("куратор",)
# Вебхуки в расписание давности не попадают
assert {"deploy", "komodo", "t3code"}.isdisjoint(crons)
async def test_job_alarms_on_a_silent_job_and_logs_the_unguarded(
tmp_path, monkeypatch, caplog
):
from beaver_agent.jobs import runs as job
monkeypatch.setattr(job, "STATE_FILE", tmp_path / "прогоны.json")
monkeypatch.setattr(job, "BOOT", datetime.now(UTC) - timedelta(days=10))
database = await make_db(
tmp_path,
[
record("закрытие", "done", ago=60 * 50),
*(record("прогоны", "done", ago=60 * h - 15) for h in range(1, 73)),
record("вайбграм", "done", ago=5),
record("deploy", "done", ago=60 * 24 * 20),
],
)
run_ = FakeRun(
database,
jobs=[
("закрытие", "20 4 * * *"),
("вайбграм", "*/10 * * * *"),
("куратор", "0 2,8,12,16,20 * * *"),
("deploy", None),
("новый", "0 3 * * *"),
],
)
with caplog.at_level("INFO", logger=job.__name__):
await job.watch_runs(cast("Any", run_))
(spawned,) = run_.spawned
text = spawned[2]
assert "ПЕРЕСТАЛИ ЗАПУСКАТЬСЯ" in text
assert "джоб `закрытие` не запускался 2 суток при периоде 24 часа" in text
assert "deploy" not in text and "новый" not in text
assert "давностью не охраняются" in caplog.text and "куратор" in caplog.text
assert "ещё ни разу не запускались: новый" in caplog.text
assert "закрытие" in Runs.load(tmp_path / "прогоны.json").quiet
# В те же сутки не повторяется
await job.watch_runs(cast("Any", run_))
assert len(run_.spawned) == 1
await database.dispose()