feat(agent): время последнего вызова Jira восстанавливается из журнала после перезапуска (M-9, #9)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-13 22:51:51 +04:00
parent b045aed8b6
commit 12858c2718
3 changed files with 72 additions and 0 deletions

View File

@ -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:

View File

@ -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()

View File

@ -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)