From 12858c271899fcb330a785e40e9143a93d105ccf Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 13 Oct 2026 22:51:51 +0400 Subject: [PATCH] =?UTF-8?q?feat(agent):=20=D0=B2=D1=80=D0=B5=D0=BC=D1=8F?= =?UTF-8?q?=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=D0=B4=D0=BD=D0=B5=D0=B3=D0=BE?= =?UTF-8?q?=20=D0=B2=D1=8B=D0=B7=D0=BE=D0=B2=D0=B0=20Jira=20=D0=B2=D0=BE?= =?UTF-8?q?=D1=81=D1=81=D1=82=D0=B0=D0=BD=D0=B0=D0=B2=D0=BB=D0=B8=D0=B2?= =?UTF-8?q?=D0=B0=D0=B5=D1=82=D1=81=D1=8F=20=D0=B8=D0=B7=20=D0=B6=D1=83?= =?UTF-8?q?=D1=80=D0=BD=D0=B0=D0=BB=D0=B0=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5?= =?UTF-8?q?=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B7=D0=B0=D0=BF=D1=83=D1=81=D0=BA?= =?UTF-8?q?=D0=B0=20(M-9,=20#9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/agent.py | 31 +++++++++++++++++++++++++++++++ tests/test_journal.py | 15 +++++++++++++++ tests/test_runner.py | 26 ++++++++++++++++++++++++++ 3 files changed, 72 insertions(+) diff --git a/src/agent.py b/src/agent.py index ca8a7ee..a569d8d 100644 --- a/src/agent.py +++ b/src/agent.py @@ -345,6 +345,20 @@ class Journal: log("журнал: строка %d не читается, пропускаю" % n) return good + def last_at(self) -> Optional[datetime]: + """Самое позднее время записи журнала (UTC). Записи без времени или с непонятным временем пропускаются.""" + latest = None + for r in self.records(): + try: + t = datetime.fromisoformat(r["at"]) + except (KeyError, TypeError, ValueError): + continue + if t.tzinfo is None: + continue + if latest is None or t > latest: + latest = t + return latest + def claimed(self, command_id) -> None: """Команда получена из claim (до паузы): с этого момента хаб считает её выданной агенту.""" self.append({"kind": "claimed", "command_id": int(command_id)}) @@ -916,6 +930,22 @@ class Runner: self.executor.sleep = self.sleep log("настройки хаба v%s (проект %s)" % (self.settings.get("settings_version"), self.settings.get("project_key"))) + def restore_pace(self) -> None: + """После перезапуска время последнего вызова Jira неизвестно (живёт в памяти). Берём самое позднее время записи + журнала: finished пишется сразу после последнего вызова команды, оценка не раньше настоящего вызова (M-9).""" + if self.jira.last_call_at is not None: + return + at = self.journal.last_at() + if at is None: + return + ago = (datetime.now(timezone.utc) - at).total_seconds() + if ago < 0: + log("журнал: время последней записи в будущем (%s) — темп не восстанавливаю" % at.isoformat()) + return + self.jira.last_call_at = time.monotonic() - ago + self.jira.last_call_iso = at.astimezone(timezone.utc).replace(microsecond=0).isoformat() + log("темп: последний вызов Jira не позже %s (по журналу)" % self.jira.last_call_iso) + def send_result(self, rec: dict) -> bool: """Отчёт хабу; повторяется каждые 10 с, пока хаб не подтвердит. 404/409/422 — хаб отверг (команда отменена или удалена): считаем закрытой, чтобы не зациклиться. При остановке по сигналу делается одна попытка.""" @@ -1056,6 +1086,7 @@ class Runner: self.sleep(wait) def loop(self) -> None: + self.restore_pace() idle, recovered = 0, False while not self.stop: if self.settings is None: diff --git a/tests/test_journal.py b/tests/test_journal.py index 9a99603..5e63fef 100644 --- a/tests/test_journal.py +++ b/tests/test_journal.py @@ -1,5 +1,6 @@ import json import os +from datetime import datetime, timezone import tempfile import unittest from unittest import mock @@ -134,6 +135,20 @@ class JournalTest(unittest.TestCase): mode = os.stat(self.path).st_mode & 0o777 self.assertEqual(mode & 0o077, 0) + def test_last_at_is_latest_record_time(self): + self.assertIsNone(self.j.last_at()) + self.j.append({"kind": "claimed", "command_id": 1, "at": "2026-10-13T10:00:00+00:00"}) + self.j.append({"kind": "finished", "command_id": 1, "at": "2026-10-13T10:05:00+00:00"}) + self.j.append({"kind": "acked", "command_id": 1, "at": "2026-10-13T10:04:00+00:00"}) + self.assertEqual(self.j.last_at(), datetime(2026, 10, 13, 10, 5, tzinfo=timezone.utc)) + + def test_last_at_skips_bad_and_missing_times(self): + self.j.append({"kind": "claimed", "command_id": 1, "at": "2026-10-13T10:00:00+00:00"}) + self.j.append({"kind": "started", "command_id": 1, "at": "не время"}) + self.j.append({"kind": "started", "command_id": 1, "at": "2026-10-13T11:00:00"}) # без зоны — пропуск + with open(self.path, "a", encoding="utf-8") as f: + f.write("битая строка\n") + self.assertEqual(self.j.last_at(), datetime(2026, 10, 13, 10, 0, tzinfo=timezone.utc)) if __name__ == "__main__": unittest.main() diff --git a/tests/test_runner.py b/tests/test_runner.py index 10c2a70..610f3a9 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -2,6 +2,7 @@ import os import tempfile import time import unittest +from datetime import datetime, timedelta, timezone from _common import SETTINGS, agent, jira_cfg, no_sleep from fake_hub import FakeHub @@ -50,6 +51,31 @@ class RunnerTest(unittest.TestCase): self.assertEqual(self.kinds(), [("claimed", 1), ("started", 1), ("finished", 1), ("acked", 1), ("claimed", 2), ("started", 2), ("finished", 2), ("acked", 2)]) self.assertEqual(self.sleeps, []) # пауз не было: last_call_at пуст, потом pause 0 + def test_restart_pace_from_old_finished(self): + at = (datetime.now(timezone.utc) - timedelta(seconds=10)).replace(microsecond=0).isoformat() + self.journal.append({"kind": "finished", "command_id": 99, "status": "done", "result": None, "error": None, + "jira_calls": 1, "rate_limited": 0, "at": at}) + self.journal.append({"kind": "acked", "command_id": 99, "at": at}) + self.hub.enqueue("ensure_fields", {"key": "AURORA-1", "duedate": "2026-10-23"}, pause=30) + self.runner.loop() + self.assertAlmostEqual(sum(self.sleeps), 20, delta=1.5) + + def test_future_journal_time_ignored(self): + at = (datetime.now(timezone.utc) + timedelta(hours=1)).replace(microsecond=0).isoformat() + self.journal.append({"kind": "acked", "command_id": 99, "at": at}) + self.hub.enqueue("ensure_fields", {"key": "AURORA-1", "duedate": "2026-10-23"}, pause=30) + self.runner.loop() + self.assertEqual(self.sleeps, []) # время из будущего — как пустой журнал + self.assertEqual(self.hub.results[1]["status"], "done") + + def test_restore_pace_keeps_existing_last_call(self): + at = (datetime.now(timezone.utc) - timedelta(seconds=100)).replace(microsecond=0).isoformat() + self.journal.append({"kind": "acked", "command_id": 99, "at": at}) + mark = time.monotonic() + self.runner.jira.last_call_at = mark + self.runner.restore_pace() + self.assertEqual(self.runner.jira.last_call_at, mark) + def test_pause_waits_from_last_jira_call(self): self.runner.jira.last_call_at = time.monotonic() self.hub.enqueue("ensure_fields", {"key": "AURORA-1", "duedate": "2026-10-23"}, pause=3)