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:
Andrey Epifancev 2026-10-12 00:51:07 +04:00
parent 529d7f3b2d
commit 527fd89be0
3 changed files with 39 additions and 13 deletions

View File

@ -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 с на попытку |

View File

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

View File

@ -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 = []