From 527fd89be0255d1b049807d9aa2d5da7bc01a17b Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 12 Oct 2026 00:51:07 +0400 Subject: [PATCH] =?UTF-8?q?fix(runner):=20=D0=BF=D0=BE=D0=B2=D1=82=D0=BE?= =?UTF-8?q?=D1=80=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=20=D0=BE=D0=B1=D1=80?= =?UTF-8?q?=D1=8B=D0=B2=D0=B0=20Jira=20=D0=B6=D0=B4=D1=91=D1=82=2030=20?= =?UTF-8?q?=D1=81=20=D0=BE=D1=82=20=D0=BC=D0=BE=D0=BC=D0=B5=D0=BD=D1=82?= =?UTF-8?q?=D0=B0=20=D0=BE=D1=82=D0=BA=D0=B0=D0=B7=D0=B0=20(F-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Обрыв соединения не обновляет last_call_at, поэтому пауза перед повтором, считавшаяся от последнего успешного вызова, при старом значении была нулевой и три повтора шли подряд. Ожидание вынесено в Runner.wait_seconds и для повтора считается от момента отказа; wait_pause использует его же и больше не принимает force. Запись R-13 в design.md обновлена. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014Typ5sXWNMFirr2va2uL8o --- openspec/changes/bridge-core/design.md | 2 +- src/agent.py | 29 +++++++++++++++----------- tests/test_runner.py | 21 +++++++++++++++++++ 3 files changed, 39 insertions(+), 13 deletions(-) diff --git a/openspec/changes/bridge-core/design.md b/openspec/changes/bridge-core/design.md index 9350dce..3eec1c0 100644 --- a/openspec/changes/bridge-core/design.md +++ b/openspec/changes/bridge-core/design.md @@ -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 с на попытку | diff --git a/src/agent.py b/src/agent.py index 7c45cc8..d1ec045 100644 --- a/src/agent.py +++ b/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) diff --git a/tests/test_runner.py b/tests/test_runner.py index 6e62b24..10c2a70 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -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 = []