feat(agent): восстановление с паузой из хаба и без повторного claimed; версия 3 (M-9, FM-4, #9)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-13 22:55:52 +04:00
parent 12858c2718
commit a95f7d936c
4 changed files with 27 additions and 18 deletions

View File

@ -4,7 +4,7 @@
выполняет команды «желаемый результат» в Jira по правилу «проверь, потом сделай», ведёт журнал на диске выполняет команды «желаемый результат» в Jira по правилу «проверь, потом сделай», ведёт журнал на диске
и возвращает хабу состояние задач. Один файл `src/agent.py`, только стандартная библиотека, Python 3.9. и возвращает хабу состояние задач. Один файл `src/agent.py`, только стандартная библиотека, Python 3.9.
Спека: `../jira-hub/openspec/changes/bridge-core/specs/agent/spec.md` (вместе с `commands/` и `hub-api/`). Спека: `../jira-hub/openspec/specs/agent/spec.md` (вместе с `commands/` и `hub-api/`).
Хаб, очередь команд и зеркало задач живут в репозитории `jira-hub`; агент с ним общается только по HTTP. Хаб, очередь команд и зеркало задач живут в репозитории `jira-hub`; агент с ним общается только по HTTP.
## Доставка на виртуалку ## Доставка на виртуалку
@ -70,7 +70,7 @@ nohup python3 -u agent.py > agent.log 2>&1 &
Остановка: Ctrl-C или `kill <pid>`. Первый сигнал — агент доделывает текущую команду, отправляет отчёт и выходит. Остановка: Ctrl-C или `kill <pid>`. Первый сигнал — агент доделывает текущую команду, отправляет отчёт и выходит.
Повторный сигнал — немедленный выход (код 130). Остановка во время паузы перед командой безопасна: команда уже Повторный сигнал — немедленный выход (код 130). Остановка во время паузы перед командой безопасна: команда уже
записана в журнал как `claimed`, при следующем запуске агент запрашивает её у хаба и выполняет через проверку (без паузы). записана в журнал как `claimed`, при следующем запуске агент запрашивает её у хаба и выполняет через проверку, выдержав паузу темпа от последней записи журнала.
## Автозапуск через launchd (опционально) ## Автозапуск через launchd (опционально)
@ -140,8 +140,11 @@ python3 agent.py prune
Правила работы: Правила работы:
- **Проверь, потом сделай.** Каждый тип сначала читает текущее состояние и записывает только разницу. - **Проверь, потом сделай.** Каждый тип сначала читает текущее состояние и записывает только разницу.
- **Паузы.** Хаб выдаёт команду с `pause_seconds` — агент досыпает до зазора от последнего вызова Jira. Внутри команды - **Паузы.** Хаб выдаёт команду с `pause_seconds` — агент досыпает до зазора от последнего вызова Jira. Внутри команды
с несколькими записями зазор берётся из `pacing` настроек хаба. с несколькими записями зазор берётся из `pacing` настроек хаба. После перезапуска время последнего вызова Jira
берётся из журнала (самая поздняя запись), поэтому первая команда тоже ждёт паузу.
- **Одна команда в полёте.** Агент берёт следующую команду, только когда предыдущая выполнена и отчёт подтверждён. - **Одна команда в полёте.** Агент берёт следующую команду, только когда предыдущая выполнена и отчёт подтверждён.
На этом строится довыдача: если ответ на `claim` потерян, хаб выдаст ту же команду снова при следующем `claim`
(до 3 выдач, затем `failed`).
- **Отчёт до подтверждения.** Отчёт повторяется каждые 10 с, пока хаб не подтвердит. Ответы 404/409/422 хаб - **Отчёт до подтверждения.** Отчёт повторяется каждые 10 с, пока хаб не подтвердит. Ответы 404/409/422 хаб
выдаёт, когда команда уже отменена или удалена: агент считает её закрытой и не зацикливается. выдаёт, когда команда уже отменена или удалена: агент считает её закрытой и не зацикливается.
- **429 и 503.** Агент ждёт `Retry-After` из ответа Jira и повторяет запрос: до 5 запросов, то есть не более 4 ожиданий; - **429 и 503.** Агент ждёт `Retry-After` из ответа Jira и повторяет запрос: до 5 запросов, то есть не более 4 ожиданий;
@ -150,7 +153,7 @@ python3 agent.py prune
`finished`, ждёт не меньше 30 с и повторяет команду через проверку («проверь, потом сделай») до 3 раз. Счётчики `finished`, ждёт не меньше 30 с и повторяет команду через проверку («проверь, потом сделай») до 3 раз. Счётчики
суммируются. После исчерпания — `failed` «результат неизвестен (обрыв связи с Jira), повтор безопасен». При остановке суммируются. После исчерпания — `failed` «результат неизвестен (обрыв связи с Jira), повтор безопасен». При остановке
по сигналу команду подхватит восстановление. по сигналу команду подхватит восстановление.
- **Версия.** Константа `AGENT_VERSION` в `agent.py` (сейчас 2) отправляется в каждом `claim`, в каждом отчёте - **Версия.** Константа `AGENT_VERSION` в `agent.py` (сейчас 3) отправляется в каждом `claim`, в каждом отчёте
(`agent_version`) и в `/agent/state`. (`agent_version`) и в `/agent/state`.
Если хаб назначил `min_agent_version` выше, он пишет в `agent_status.warning`: «обновите агента»; команды Если хаб назначил `min_agent_version` выше, он пишет в `agent_status.warning`: «обновите агента»; команды
при этом выдаются по-прежнему. при этом выдаются по-прежнему.
@ -177,9 +180,9 @@ python3 agent.py prune
1. **`prune` не запускать при работающем агенте.** Блокировки нет. Запись, добавленная агентом во время `prune`, 1. **`prune` не запускать при работающем агенте.** Блокировки нет. Запись, добавленная агентом во время `prune`,
может пропасть при перезаписи файла. Сначала остановить агента. может пропасть при перезаписи файла. Сначала остановить агента.
2. **Остановка во время паузы или между claim и выполнением (R-9).** Команда остаётся `claimed` в хабе, но в журнале 2. **Остановка во время паузы или между claim и выполнением (R-9).** Команда остаётся `claimed` в хабе, но в журнале
есть запись `claimed`: при перезапуске агент запрашивает её у хаба и выполняет без паузы. Отмена с force и повтор есть запись `claimed`: при перезапуске агент запрашивает её у хаба и выполняет с паузой темпа (время последнего
больше не нужны. Если журнал потерян, команду придётся отменить с force (`POST /commands/{id}/cancel`, вызова Jira берётся из журнала). Отмена с force и повтор не нужны. Если журнал потерян, хаб довыдаст команду при
`{"force": true}`) и повторить (`POST /commands/{id}/retry`). следующем `claim` — агент выполнит её через проверку.
3. **HTTPS хаба с корпоративным CA не поддержан.** `HubClient` работает без `ca_bundle` и `verify_ssl`: `hub_url` 3. **HTTPS хаба с корпоративным CA не поддержан.** `HubClient` работает без `ca_bundle` и `verify_ssl`: `hub_url`
должен быть `http://`. Для Jira `verify_ssl` и `ca_bundle` работают. должен быть `http://`. Для Jira `verify_ssl` и `ca_bundle` работают.
4. **Спринт в `refresh` — только активные и будущие спринты доски.** Поле `sprint` берётся из Agile API по 4. **Спринт в `refresh` — только активные и будущие спринты доски.** Поле `sprint` берётся из Agile API по

View File

@ -3,7 +3,7 @@
Опрашивает хаб (jira-hub) на Mac mini, выполняет команды «желаемый результат» в Jira по правилу Опрашивает хаб (jira-hub) на Mac mini, выполняет команды «желаемый результат» в Jira по правилу
«проверь, потом сделай», ведёт журнал на диске и возвращает хабу состояние задач. Только стандартная библиотека, «проверь, потом сделай», ведёт журнал на диске и возвращает хабу состояние задач. Только стандартная библиотека,
Python 3.9. Спека: jira-hub/openspec/changes/bridge-core/specs/agent/spec.md. Python 3.9. Спека: jira-hub/openspec/specs/agent/spec.md.
Связь с хабом — HTTP (`hub_url = http://…`) или общая папка (`hub_url = folder:<путь>`), если сеть закрыта VPN. Связь с хабом — HTTP (`hub_url = http://…`) или общая папка (`hub_url = folder:<путь>`), если сеть закрыта VPN.
python3 agent.py check # доступ к хабу и Jira, без записи python3 agent.py check # доступ к хабу и Jira, без записи
@ -26,7 +26,7 @@ import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
AGENT_VERSION = 2 AGENT_VERSION = 3
HERE = os.path.dirname(os.path.abspath(__file__)) HERE = os.path.dirname(os.path.abspath(__file__))
API = "/rest/api/2" API = "/rest/api/2"
AGILE = "/rest/agile/1.0" AGILE = "/rest/agile/1.0"
@ -999,10 +999,11 @@ class Runner:
"error": "внутренняя ошибка агента: %s: %s" % (type(e).__name__, str(e)[:300]), "error": "внутренняя ошибка агента: %s: %s" % (type(e).__name__, str(e)[:300]),
"jira_calls": self.jira.calls, "rate_limited": self.jira.rate_limited} "jira_calls": self.jira.calls, "rate_limited": self.jira.rate_limited}
def execute(self, cmd: dict) -> None: def execute(self, cmd: dict, journal_claimed: bool = True) -> None:
cid = cmd["id"] cid = cmd["id"]
log("#%s %s %s" % (cid, cmd["type"], json.dumps(cmd.get("params"), ensure_ascii=False)[:200])) log("#%s %s %s" % (cid, cmd["type"], json.dumps(cmd.get("params"), ensure_ascii=False)[:200]))
self.journal.claimed(cid) # сразу после claim, до паузы (R-9): остановка не теряет команду if journal_claimed:
self.journal.claimed(cid) # сразу после claim, до паузы (R-9): остановка не теряет команду
if not self.wait_pause(cmd.get("pause_seconds")): if not self.wait_pause(cmd.get("pause_seconds")):
return return
self.journal.started(cid) # started — только после паузы self.journal.started(cid) # started — только после паузы
@ -1064,8 +1065,7 @@ class Runner:
self.journal.acked(cid) self.journal.acked(cid)
continue continue
log("#%s выдана до перезапуска — выполняю через проверку" % cid) log("#%s выдана до перезапуска — выполняю через проверку" % cid)
cmd["pause_seconds"] = 0 self.execute(cmd, journal_claimed=False) # claimed/started для неё уже в журнале (FM-4); пауза — из хаба (M-9)
self.execute(cmd)
def reload_settings_before(self, cmd) -> bool: def reload_settings_before(self, cmd) -> bool:
"""Настройки сменились: перечитать ДО выполнения. False — остановка, команда не должна выполняться """Настройки сменились: перечитать ДО выполнения. False — остановка, команда не должна выполняться

View File

@ -135,8 +135,8 @@ class FolderIntegrationTest(unittest.TestCase):
def cfg(self): def cfg(self):
return jira_cfg(self.fake, hub_url="folder:" + self.root, hub_token=self.hub.token) return jira_cfg(self.fake, hub_url="folder:" + self.root, hub_token=self.hub.token)
def test_version_is_2(self): def test_version_is_3(self):
self.assertEqual(agent.AGENT_VERSION, 2) self.assertEqual(agent.AGENT_VERSION, 3)
def test_runner_through_folder(self): def test_runner_through_folder(self):
self.fake.add("AURORA-1", duedate="2026-10-20") self.fake.add("AURORA-1", duedate="2026-10-20")
@ -149,7 +149,7 @@ class FolderIntegrationTest(unittest.TestCase):
runner.idle_limit = 1 runner.idle_limit = 1
runner.loop() runner.loop()
self.assertEqual(self.hub.results[cid]["status"], "done") self.assertEqual(self.hub.results[cid]["status"], "done")
self.assertEqual(self.hub.results[cid]["agent_version"], 2) self.assertEqual(self.hub.results[cid]["agent_version"], agent.AGENT_VERSION)
self.assertEqual(self.fake.issues["AURORA-1"]["duedate"], "2026-10-23") self.assertEqual(self.fake.issues["AURORA-1"]["duedate"], "2026-10-23")
self.assertIn(("acked", cid), [(r["kind"], r["command_id"]) for r in journal.records()]) self.assertIn(("acked", cid), [(r["kind"], r["command_id"]) for r in journal.records()])

View File

@ -129,9 +129,15 @@ class RunnerTest(unittest.TestCase):
self.runner.loop() self.runner.loop()
self.assertEqual(self.hub.results[cid]["result"], {"changed": []}) # уже так в Jira — записи нет self.assertEqual(self.hub.results[cid]["result"], {"changed": []}) # уже так в Jira — записи нет
self.assertEqual(self.journal.unfinished_started(), []) self.assertEqual(self.journal.unfinished_started(), [])
self.assertEqual(self.sleeps, []) # пауза при восстановлении не применяется self.assertAlmostEqual(sum(self.sleeps), 99, delta=1) # пауза из хаба, от записи started (M-9)
self.assertEqual([r[0] for r in self.fake.writes()], []) self.assertEqual([r[0] for r in self.fake.writes()], [])
def test_recover_does_not_journal_claimed_twice(self):
cid = self.claim_in_flight("ensure_fields", {"key": "AURORA-1", "duedate": "2026-10-20"}, pause=0)
self.journal.claimed(cid)
self.runner.loop()
self.assertEqual(self.kinds(), [("claimed", cid), ("started", cid), ("finished", cid), ("acked", cid)])
def test_recover_ensure_issue_finds_duplicate(self): def test_recover_ensure_issue_finds_duplicate(self):
self.fake.add("AURORA-7", reporter="me", summary="Новая задача") self.fake.add("AURORA-7", reporter="me", summary="Новая задача")
before = len(self.fake.issues) before = len(self.fake.issues)
@ -185,7 +191,7 @@ class RunnerTest(unittest.TestCase):
self.assertEqual(self.hub.results[1]["status"], "done") self.assertEqual(self.hub.results[1]["status"], "done")
self.assertEqual(len(self.fake.writes()), 1) self.assertEqual(len(self.fake.writes()), 1)
self.assertEqual(self.journal.unfinished_started(), []) self.assertEqual(self.journal.unfinished_started(), [])
self.assertEqual(self.sleeps, []) # при восстановлении пауза не применяется self.assertAlmostEqual(sum(self.sleeps), 5, delta=1) # пауза из хаба от записи claimed (M-9)
def test_stop_between_claim_and_execute_is_recorded(self): def test_stop_between_claim_and_execute_is_recorded(self):
self.hub.enqueue("ensure_fields", {"key": "AURORA-1", "duedate": "2026-10-23"}) self.hub.enqueue("ensure_fields", {"key": "AURORA-1", "duedate": "2026-10-23"})