feat(hands,jobs): job-run watchdog looks a week back and alarms on jobs that stopped running
This commit is contained in:
1 parent
426b4055f4
commit
91bd50a66d
6 files changed
+620
-71
No files matched your search
+230
-4
@@ -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()
|
||||
Reference in new issue
Block a user