feat(agent): время последнего вызова Jira восстанавливается из журнала после перезапуска (M-9, #9)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
b045aed8b6
commit
12858c2718
31
src/agent.py
31
src/agent.py
@ -345,6 +345,20 @@ class Journal:
|
|||||||
log("журнал: строка %d не читается, пропускаю" % n)
|
log("журнал: строка %d не читается, пропускаю" % n)
|
||||||
return good
|
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:
|
def claimed(self, command_id) -> None:
|
||||||
"""Команда получена из claim (до паузы): с этого момента хаб считает её выданной агенту."""
|
"""Команда получена из claim (до паузы): с этого момента хаб считает её выданной агенту."""
|
||||||
self.append({"kind": "claimed", "command_id": int(command_id)})
|
self.append({"kind": "claimed", "command_id": int(command_id)})
|
||||||
@ -916,6 +930,22 @@ class Runner:
|
|||||||
self.executor.sleep = self.sleep
|
self.executor.sleep = self.sleep
|
||||||
log("настройки хаба v%s (проект %s)" % (self.settings.get("settings_version"), self.settings.get("project_key")))
|
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:
|
def send_result(self, rec: dict) -> bool:
|
||||||
"""Отчёт хабу; повторяется каждые 10 с, пока хаб не подтвердит. 404/409/422 — хаб отверг (команда отменена
|
"""Отчёт хабу; повторяется каждые 10 с, пока хаб не подтвердит. 404/409/422 — хаб отверг (команда отменена
|
||||||
или удалена): считаем закрытой, чтобы не зациклиться. При остановке по сигналу делается одна попытка."""
|
или удалена): считаем закрытой, чтобы не зациклиться. При остановке по сигналу делается одна попытка."""
|
||||||
@ -1056,6 +1086,7 @@ class Runner:
|
|||||||
self.sleep(wait)
|
self.sleep(wait)
|
||||||
|
|
||||||
def loop(self) -> None:
|
def loop(self) -> None:
|
||||||
|
self.restore_pace()
|
||||||
idle, recovered = 0, False
|
idle, recovered = 0, False
|
||||||
while not self.stop:
|
while not self.stop:
|
||||||
if self.settings is None:
|
if self.settings is None:
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
from datetime import datetime, timezone
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
@ -134,6 +135,20 @@ class JournalTest(unittest.TestCase):
|
|||||||
mode = os.stat(self.path).st_mode & 0o777
|
mode = os.stat(self.path).st_mode & 0o777
|
||||||
self.assertEqual(mode & 0o077, 0)
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import os
|
|||||||
import tempfile
|
import tempfile
|
||||||
import time
|
import time
|
||||||
import unittest
|
import unittest
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
from _common import SETTINGS, agent, jira_cfg, no_sleep
|
from _common import SETTINGS, agent, jira_cfg, no_sleep
|
||||||
from fake_hub import FakeHub
|
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.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
|
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):
|
def test_pause_waits_from_last_jira_call(self):
|
||||||
self.runner.jira.last_call_at = time.monotonic()
|
self.runner.jira.last_call_at = time.monotonic()
|
||||||
self.hub.enqueue("ensure_fields", {"key": "AURORA-1", "duedate": "2026-10-23"}, pause=3)
|
self.hub.enqueue("ensure_fields", {"key": "AURORA-1", "duedate": "2026-10-23"}, pause=3)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user