fix(runner): повтор после обрыва Jira ждёт 30 с от момента отказа (F-1)
Обрыв соединения не обновляет last_call_at, поэтому пауза перед повтором, считавшаяся от последнего успешного вызова, при старом значении была нулевой и три повтора шли подряд. Ожидание вынесено в Runner.wait_seconds и для повтора считается от момента отказа; wait_pause использует его же и больше не принимает force. Запись R-13 в design.md обновлена. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014Typ5sXWNMFirr2va2uL8o
This commit is contained in:
parent
529d7f3b2d
commit
527fd89be0
@ -18,4 +18,4 @@
|
||||
| 10 | 2026-10-12 | `Runner.execute` повторяет команду до 3 раз через проверку при ConnectionError/HTTP 5xx, не записывая `finished`; затем `failed` «результат неизвестен, повтор безопасен» (R-10) | Неоднозначный исход записи в Jira (обрыв после POST) нельзя отдавать как `failed`: для `ensure_issue` хаб пропустит зависимые команды; все команды идемпотентны | Лишние задержки при недоступной Jira |
|
||||
| 11 | 2026-10-12 | Запрошенное, но не настроенное поле (epic_key, planned_start/end) даёт `failed`, а не молчаливый `done` (R-11) | Ложный успех хаб запишет в зеркало как выполненный | Команды падают, пока хаб не настроен |
|
||||
| 12 | 2026-10-12 | `agent_version` добавляется в отчёт и в `/agent/state` (R-12) | Спека: версия «в каждом отчёте»; хаб лишние поля игнорирует | Нет |
|
||||
| 13 | 2026-10-12 | F-1 (повтор по R-10 не ждёт 30 с, если Jira недоступна с первого вызова и `last_call_at` старый) оставлен как есть, deferred (R-13) | Вторая волна фиксов не предусмотрена процессом; последствие — три быстрых повтора и `failed «результат неизвестен», повтор безопасен`: без потери данных и без дублей (поиск дубля идемпотентен) | Недоступная Jira даст `failed` за секунды вместо ~90 с; лечится фиксированным сном `max(pause,30)` от момента отказа в ветке retry |
|
||||
| 13 | 2026-10-12 | F-1 (повтор по R-10 не ждёт 30 с, если Jira недоступна с первого вызова и `last_call_at` старый) сначала отложен контроллером (вторая волна фиксов не предусмотрена), затем исправлен по команде владельца: ожидание перед повтором — фиксированное `max(pause, 30)` от момента отказа (`Runner.wait_seconds`), не от последнего успешного вызова Jira | Обрыв соединения не обновляет `last_call_at`, поэтому пауза «от последнего вызова» при старом значении была нулевой и три повтора шли подряд | Повтор ждёт полные 30 с даже если с последнего вызова Jira прошло больше; лишнее ожидание не дольше 30 с на попытку |
|
||||
|
||||
29
src/agent.py
29
src/agent.py
@ -852,19 +852,23 @@ class Runner:
|
||||
return False
|
||||
self.sleep(wait)
|
||||
|
||||
def wait_pause(self, pause_seconds, force=False) -> bool:
|
||||
def wait_seconds(self, seconds) -> bool:
|
||||
"""Спать заданное время шагами по секунде; False — остановлены во время ожидания."""
|
||||
remaining = float(seconds or 0)
|
||||
if remaining > 0:
|
||||
log(" пауза %.0f с" % remaining)
|
||||
while remaining > 0 and not self.stop:
|
||||
step = min(remaining, 1.0)
|
||||
self.sleep(step)
|
||||
remaining -= step
|
||||
return not self.stop
|
||||
|
||||
def wait_pause(self, pause_seconds) -> bool:
|
||||
"""Досыпать до зазора от последнего вызова Jira; False — остановлены во время паузы.
|
||||
force: если вызовов Jira ещё не было, считать от текущего момента."""
|
||||
Если вызовов Jira ещё не было — пауза не нужна."""
|
||||
pause = float(pause_seconds or 0)
|
||||
if pause > 0 and (self.jira.last_call_at is not None or force):
|
||||
since = time.monotonic() - self.jira.last_call_at if self.jira.last_call_at is not None else 0
|
||||
remaining = pause - since
|
||||
if remaining > 0:
|
||||
log(" пауза %.0f с" % remaining)
|
||||
while remaining > 0 and not self.stop:
|
||||
step = min(remaining, 1.0)
|
||||
self.sleep(step)
|
||||
remaining -= step
|
||||
if pause > 0 and self.jira.last_call_at is not None:
|
||||
return self.wait_seconds(pause - (time.monotonic() - self.jira.last_call_at))
|
||||
return not self.stop
|
||||
|
||||
def run_once(self, cmd: dict) -> dict:
|
||||
@ -896,7 +900,8 @@ class Runner:
|
||||
break
|
||||
attempt += 1
|
||||
log(" ! обрыв связи с Jira, повтор %d/%d через проверку: %s" % (attempt, self.RETRY_ATTEMPTS, out.get("error")))
|
||||
if not self.wait_pause(max(float(cmd.get("pause_seconds") or 0), self.RETRY_MIN_WAIT), force=True):
|
||||
# F-1: обрыв не обновляет last_call_at, поэтому ждём фиксированно от момента отказа, а не от последнего вызова
|
||||
if not self.wait_seconds(max(float(cmd.get("pause_seconds") or 0), self.RETRY_MIN_WAIT)):
|
||||
return # остановка: finished не пишем, команду подхватит recover
|
||||
out["jira_calls"], out["rate_limited"] = calls, limited
|
||||
self.journal.finished(cid, out["status"], out.get("result"), out.get("error"), calls, limited)
|
||||
|
||||
@ -276,6 +276,27 @@ class RunnerTest(unittest.TestCase):
|
||||
self.assertEqual(r["jira_calls"], 2) # поиск + повторный поиск (оборванный ответ не считается)
|
||||
self.assertEqual([k for k, _ in self.kinds()].count("started"), 1)
|
||||
|
||||
def test_retry_waits_from_failure_even_if_last_call_is_old(self):
|
||||
# F-1: Jira недоступна с первого вызова, last_call_at старый (обрыв не обновляет его) —
|
||||
# ожидание перед повтором считается от момента отказа, а не от последнего успешного вызова.
|
||||
self.runner.load_settings()
|
||||
self.runner.jira.last_call_at = time.monotonic() - 600
|
||||
runs = []
|
||||
|
||||
def flaky(cmd):
|
||||
runs.append(1)
|
||||
if len(runs) == 1:
|
||||
return {"status": "failed", "result": None, "error": "нет связи", "jira_calls": 0,
|
||||
"rate_limited": 0, "retryable": True}
|
||||
return {"status": "done", "result": {"changed": []}, "jira_calls": 1, "rate_limited": 0}
|
||||
|
||||
self.runner.executor.run = flaky
|
||||
self.hub.enqueue("ensure_fields", {"key": "AURORA-1", "duedate": "2026-10-23"})
|
||||
self.runner.loop()
|
||||
self.assertEqual(len(runs), 2)
|
||||
self.assertEqual(self.hub.results[1]["status"], "done")
|
||||
self.assertAlmostEqual(sum(self.sleeps), 30, delta=0.1) # полные 30 с от момента отказа
|
||||
|
||||
def test_retry_exhausted_gives_failed_unknown(self):
|
||||
self.runner.load_settings()
|
||||
runs = []
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user