Агент: оборванное тело — обрыв связи, перенаправления только на свой адрес, README v14 (#43)

По финальному ревью: M-1, M-4, M-6.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-22 14:36:29 +04:00
parent 7238691a5d
commit 3a5bd9a109
6 changed files with 68 additions and 12 deletions

View File

@ -39,7 +39,7 @@
- `tests/_common.py` — общая настройка: путь к `src`, `SETTINGS` (как их отдаёт `/agent/settings`), `jira_cfg()`, `no_sleep()`. - `tests/_common.py` — общая настройка: путь к `src`, `SETTINGS` (как их отдаёт `/agent/settings`), `jira_cfg()`, `no_sleep()`.
Паузы в тестах отключаются через `ZERO_PACING` и `no_sleep`. Паузы в тестах отключаются через `ZERO_PACING` и `no_sleep`.
- Тесты разбиты по областям, а не по типам команд: `test_executor_fields.py` (`ensure_issue`, `ensure_fields`, `ensure_comment`), - Тесты разбиты по областям, а не по типам команд: `test_executor_fields.py` (`ensure_issue`, `ensure_fields`, `ensure_comment`),
`test_executor_links.py` (`ensure_status`, `ensure_link`, `ensure_sprint`), `test_executor_attachments.py` (`ensure_attachment`), `test_refresh.py` (`refresh`), `test_executor_links.py` (`ensure_status`, `ensure_link`, `ensure_sprint`), `test_executor_attachments.py` (`ensure_attachment`, `fetch_attachment`), `test_refresh.py` (`refresh`),
`test_runner.py` (цикл и восстановление), `test_journal.py` (журнал), `test_clients.py` (HTTP и 429), `test_runner.py` (цикл и восстановление), `test_journal.py` (журнал), `test_clients.py` (HTTP и 429),
`test_update.py` (самообновление агента), `test_launcher.py` (запускатель на поддельных агентах), `test_update.py` (самообновление агента), `test_launcher.py` (запускатель на поддельных агентах),
`test_self_update_e2e.py` (`run.py` + настоящий `agent.py` + фейки). `test_self_update_e2e.py` (`run.py` + настоящий `agent.py` + фейки).

View File

@ -174,7 +174,8 @@ python3 agent.py prune
| `ensure_link` | создаёт связь; конфликт без `replace` — `failed`; с `replace` сначала удаляет лишнее | | `ensure_link` | создаёт связь; конфликт без `replace` — `failed`; с `replace` сначала удаляет лишнее |
| `ensure_sprint` | переносит в спринт доски `board_id` команды (без него — доски из настроек хаба; активные и будущие спринты) или в бэклог; доска без спринтов (канбан) — `failed` с текстом Jira | | `ensure_sprint` | переносит в спринт доски `board_id` команды (без него — доски из настроек хаба; активные и будущие спринты) или в бэклог; доска без спринтов (канбан) — `failed` с текстом Jira |
| `ensure_attachment` | ищет у задачи вложение с тем же именем и размером — есть: ничего не делает; нет: берёт файл у хаба (`/agent/files/{sha256}`), сверяет sha256 и размер и загружает в Jira (`multipart/form-data`); файл удалён с хаба — `failed` «загрузите заново»; повреждённый файл — повтор, как обрыв связи с хабом. Содержимое файла в журнал и лог не пишется | | `ensure_attachment` | ищет у задачи вложение с тем же именем и размером — есть: ничего не делает; нет: берёт файл у хаба (`/agent/files/{sha256}`), сверяет sha256 и размер и загружает в Jira (`multipart/form-data`); файл удалён с хаба — `failed` «загрузите заново»; повреждённый файл — повтор, как обрыв связи с хабом. Содержимое файла в журнал и лог не пишется |
| `refresh` | только читает Jira и отправляет состояние задач в хаб (с v13 — и журнал изменений по `history`, `POST /agent/history`); ничего не пишет; доски с неизвестным типом (`boards`, `type = null`) — запрос `/rest/agile/1.0/board/{id}`, тип в результате `boards`; спринты — со всех scrum-досок | | `fetch_attachment` | читает вложение у задачи (`fields=attachment`), качает содержимое с `<base_url>/secure/attachment/<id>/<имя>` (не больше 10 МБ; перенаправление на другой адрес — `failed`, учётные данные туда не уходят) и отдаёт хабу `POST /agent/files`; в Jira не пишет. Вложение удалено — `failed` «удалено из … в Jira». Содержимое файла в журнал и лог не пишется |
| `refresh` | только читает Jira и отправляет состояние задач в хаб (с v13 — и журнал изменений по `history`, `POST /agent/history`; с v14 — список вложений задач без содержимого); ничего не пишет; доски с неизвестным типом (`boards`, `type = null`) — запрос `/rest/agile/1.0/board/{id}`, тип в результате `boards`; спринты — со всех scrum-досок |
Правила работы: Правила работы:
- **Проверь, потом сделай.** Каждый тип сначала читает текущее состояние и записывает только разницу. - **Проверь, потом сделай.** Каждый тип сначала читает текущее состояние и записывает только разницу.
@ -192,7 +193,7 @@ python3 agent.py prune
`finished`, ждёт не меньше 30 с и повторяет команду через проверку («проверь, потом сделай») до 3 раз. Счётчики `finished`, ждёт не меньше 30 с и повторяет команду через проверку («проверь, потом сделай») до 3 раз. Счётчики
суммируются. После исчерпания — `failed` «результат неизвестен (обрыв связи с Jira), повтор безопасен». При остановке суммируются. После исчерпания — `failed` «результат неизвестен (обрыв связи с Jira), повтор безопасен». При остановке
по сигналу команду подхватит восстановление. по сигналу команду подхватит восстановление.
- **Версия.** Константа `AGENT_VERSION` в `agent.py` (сейчас 12) отправляется в каждом `claim`, в каждом отчёте - **Версия.** Константа `AGENT_VERSION` в `agent.py` (сейчас 14) отправляется в каждом `claim`, в каждом отчёте
(`agent_version`) и в `/agent/state`. Версия 4 — сравнение текстов без различия `\r\n`/`\n`, полная пауза при времени (`agent_version`) и в `/agent/state`. Версия 4 — сравнение текстов без различия `\r\n`/`\n`, полная пауза при времени
журнала в будущем, `claim` без оценки из журнала, `prune` под блокировкой. Версия 5 — самообновление через `run.py`. журнала в будущем, `claim` без оценки из журнала, `prune` под блокировкой. Версия 5 — самообновление через `run.py`.
Версия 9 — код 73 при занятом журнале, `OSError` при подготовке — временный сбой, проверка формата публикации, источник ошибки хаба в Версия 9 — код 73 при занятом журнале, `OSError` при подготовке — временный сбой, проверка формата публикации, источник ошибки хаба в
@ -205,6 +206,9 @@ python3 agent.py prune
выкатывается только после v12 (design хаба `projects`, решение 14). выкатывается только после v12 (design хаба `projects`, решение 14).
Версия 13 — журнал изменений Jira в `refresh` (параметр `history`: `since` — изменившиеся задачи, `jql` — стартовая Версия 13 — журнал изменений Jira в `refresh` (параметр `history`: `since` — изменившиеся задачи, `jql` — стартовая
загрузка), отправка `POST /agent/history`, итог в `result.history` (design хаба `change-history`). загрузка), отправка `POST /agent/history`, итог в `result.history` (design хаба `change-history`).
Версия 14 — список вложений задач в состоянии `refresh` и команда `fetch_attachment` (design хаба `attachment-fetch`);
перенаправления HTTP — только на адрес клиента (Jira или хаб), на другой адрес — отказ. Агент v13 на
`fetch_attachment` не нужен: хаб до v14 скачивание не ставит (409 «нужен v14»).
Если хаб назначил `min_agent_version` выше, он пишет в `agent_status.warning`: «обновите агента»; команды Если хаб назначил `min_agent_version` выше, он пишет в `agent_status.warning`: «обновите агента»; команды
при этом выдаются по-прежнему. при этом выдаются по-прежнему.

View File

@ -172,9 +172,12 @@ class Http:
if content_type is not None: if content_type is not None:
req.add_header("Content-Type", content_type) req.add_header("Content-Type", content_type)
try: try:
with urllib.request.urlopen(req, timeout=timeout or self.timeout, context=self.ctx) as resp: with self._opener().open(req, timeout=timeout or self.timeout) as resp:
raw = resp.read() raw = resp.read()
status = resp.status status = resp.status
except RedirectAway as e:
self._touch()
raise HttpError(302, "перенаправление на %s отклонено: учётные данные туда не отправляются" % e.host) from None
except urllib.error.HTTPError as e: except urllib.error.HTTPError as e:
self._touch() self._touch()
try: try:
@ -193,6 +196,14 @@ class Http:
except ValueError as e: # включая UnicodeDecodeError и JSONDecodeError except ValueError as e: # включая UnicodeDecodeError и JSONDecodeError
raise HttpError(status, "ответ не JSON: %s; начало: %s" % (e, raw[:200].decode("utf-8", "replace"))) from None raise HttpError(status, "ответ не JSON: %s; начало: %s" % (e, raw[:200].decode("utf-8", "replace"))) from None
def _opener(self):
"""Открыватель запросов: перенаправления — только на адрес клиента (urllib переносит Authorization и на
чужой хост); контекст TLS — из настроек."""
handlers = [_SameOriginRedirect(self.base)] # type: List[Any]
if self.ctx is not None:
handlers.append(urllib.request.HTTPSHandler(context=self.ctx))
return urllib.request.build_opener(*handlers)
def fetch_bytes(self, path: str, limit: int, timeout=None) -> bytes: def fetch_bytes(self, path: str, limit: int, timeout=None) -> bytes:
"""GET с ответом-байтами (содержимое вложения): без разбора JSON, читает не больше limit + 1 байт — длиннее """GET с ответом-байтами (содержимое вложения): без разбора JSON, читает не больше limit + 1 байт — длиннее
limit — TooBig. Перенаправление на другой адрес — RedirectAway без перехода. Ошибки — как у send.""" limit — TooBig. Перенаправление на другой адрес — RedirectAway без перехода. Ошибки — как у send."""
@ -200,13 +211,12 @@ class Http:
for k, v in self.headers.items(): for k, v in self.headers.items():
req.add_header(k, v) req.add_header(k, v)
req.add_header("Accept", "*/*") req.add_header("Accept", "*/*")
handlers = [_SameOriginRedirect(self.base)] # type: List[Any]
if self.ctx is not None:
handlers.append(urllib.request.HTTPSHandler(context=self.ctx))
opener = urllib.request.build_opener(*handlers)
try: try:
with opener.open(req, timeout=timeout or self.timeout) as resp: with self._opener().open(req, timeout=timeout or self.timeout) as resp:
data = resp.read(limit + 1) data = resp.read(limit + 1)
rest = resp.length # остаток по Content-Length: read(n) при обрыве молча отдаёт меньше
if rest and len(data) <= limit:
raise ConnectionError("%s: обрыв при чтении ответа: получено %d байт, не дошло %d" % (self.base, len(data), rest))
except RedirectAway: except RedirectAway:
self._touch() self._touch()
raise raise

View File

@ -90,6 +90,8 @@ class FakeJira:
self.content_gets = 0 # GET /secure/attachment/<id>/<имя> — запросы содержимого self.content_gets = 0 # GET /secure/attachment/<id>/<имя> — запросы содержимого
self.redirect_content = None # адрес для 302 на запрос содержимого (абсолютный или путь этого сервера) self.redirect_content = None # адрес для 302 на запрос содержимого (абсолютный или путь этого сервера)
self.short_content = set() # id вложений, содержимое которых отдаётся без последнего байта self.short_content = set() # id вложений, содержимое которых отдаётся без последнего байта
self.lying_length = set() # id вложений: Content-Length больше тела, соединение рвётся после тела
self.redirects = {} # путь → адрес Location: 302 вместо ответа (любой метод)
self.last_headers = {} # заголовки текущего запроса (имена в нижнем регистре) self.last_headers = {} # заголовки текущего запроса (имена в нижнем регистре)
self.search_comment_cap = None # сколько комментариев задачи отдаёт search (None — все), как обрезка Jira self.search_comment_cap = None # сколько комментариев задачи отдаёт search (None — все), как обрезка Jira
self.changelogs = {} # ключ → записи журнала Jira [{id, author, created, items}] self.changelogs = {} # ключ → записи журнала Jira [{id, author, created, items}]
@ -263,6 +265,12 @@ class FakeJira:
code, payload = 404, {"errorMessages": ["Issue Does Not Exist: %s" % e]} code, payload = 404, {"errorMessages": ["Issue Does Not Exist: %s" % e]}
except Exception as e: # некорректный запрос к фейку except Exception as e: # некорректный запрос к фейку
code, payload = 400, {"errorMessages": ["фейк: %s: %s" % (type(e).__name__, e)]} code, payload = 400, {"errorMessages": ["фейк: %s: %s" % (type(e).__name__, e)]}
if url.path in self.redirects:
h.send_response(302)
h.send_header("Location", self.redirects[url.path])
h.send_header("Content-Length", "0")
h.end_headers()
return
if method == "GET" and (url.path.startswith("/secure/attachment/") or url.path.startswith("/secure/moved/")): if method == "GET" and (url.path.startswith("/secure/attachment/") or url.path.startswith("/secure/moved/")):
return self._content(h, url.path) return self._content(h, url.path)
if self.drop_next_post_issue and method == "POST" and url.path == "/rest/api/2/issue": if self.drop_next_post_issue and method == "POST" and url.path == "/rest/api/2/issue":
@ -301,9 +309,13 @@ class FakeJira:
data = found["data"][:-1] if found["id"] in self.short_content else found["data"] data = found["data"][:-1] if found["id"] in self.short_content else found["data"]
h.send_response(200) h.send_response(200)
h.send_header("Content-Type", found["mimeType"]) h.send_header("Content-Type", found["mimeType"])
h.send_header("Content-Length", str(len(data))) h.send_header("Content-Length", str(len(data) + (60 if found["id"] in self.lying_length else 0)))
h.end_headers() h.end_headers()
h.wfile.write(data) h.wfile.write(data)
if found["id"] in self.lying_length:
h.wfile.flush()
h.close_connection = True
h.connection.shutdown(socket.SHUT_RDWR) # обрыв посреди тела
def _send(self, h, code, payload, headers=None): def _send(self, h, code, payload, headers=None):
data = json.dumps(payload, ensure_ascii=False).encode("utf-8") if payload is not None else b"" data = json.dumps(payload, ensure_ascii=False).encode("utf-8") if payload is not None else b""

View File

@ -133,6 +133,27 @@ class ClientsTest(unittest.TestCase):
self.assertEqual(self.jira.download(self._content_path(a), 100), b"abc") self.assertEqual(self.jira.download(self._content_path(a), 100), b"abc")
self.assertEqual(self.jira.rate_limited, 1) self.assertEqual(self.jira.rate_limited, 1)
def test_download_cut_body_is_connection_error(self): # M-1 финального ревью
self.fake.add("AURORA-1")
a = self.fake.add_attachment("AURORA-1", "a.bin", b"x" * 40)
self.fake.lying_length.add(a["id"])
with self.assertRaises(ConnectionError):
self.jira.download(self._content_path(a), 1000)
def test_json_call_redirect_other_host_refused(self): # M-4 финального ревью
self.fake.add("AURORA-1")
port = self.fake.server.server_port
self.fake.redirects["/rest/api/2/issue/AURORA-1"] = "http://localhost:%d/rest/api/2/issue/AURORA-2" % port
with self.assertRaises(agent.HttpError) as e:
self.jira.get("/rest/api/2/issue/AURORA-1")
self.assertIn("localhost", e.exception.text)
self.assertFalse(any(r[1] == "/rest/api/2/issue/AURORA-2" for r in self.fake.requests))
def test_json_call_redirect_same_host_followed(self):
self.fake.add("AURORA-2")
self.fake.redirects["/rest/api/2/issue/AURORA-1"] = self.fake.base_url + "/rest/api/2/issue/AURORA-2"
self.assertEqual(self.jira.get("/rest/api/2/issue/AURORA-1")["key"], "AURORA-2")
def test_retry_on_429_with_retry_after(self): def test_retry_on_429_with_retry_after(self):
self.fake.add("AURORA-1") self.fake.add("AURORA-1")
self.fake.fail_next.append((429, {"Retry-After": "0"})) self.fake.fail_next.append((429, {"Retry-After": "0"}))
@ -196,7 +217,7 @@ class ClientsTest(unittest.TestCase):
raise TimeoutError("timed out") raise TimeoutError("timed out")
err = urllib.error.HTTPError("http://x", 500, "boom", {}, Broken()) err = urllib.error.HTTPError("http://x", 500, "boom", {}, Broken())
with mock.patch("urllib.request.urlopen", side_effect=err): with mock.patch("urllib.request.OpenerDirector.open", side_effect=err):
with self.assertRaises(ConnectionError): with self.assertRaises(ConnectionError):
self.jira.get("/x") self.jira.get("/x")
self.assertEqual(self.jira.calls, 1) self.assertEqual(self.jira.calls, 1)
@ -204,7 +225,7 @@ class ClientsTest(unittest.TestCase):
def test_incomplete_read_is_connection_error(self): def test_incomplete_read_is_connection_error(self):
import http.client import http.client
from unittest import mock from unittest import mock
with mock.patch("urllib.request.urlopen", side_effect=http.client.IncompleteRead(b"ab", 10)): with mock.patch("urllib.request.OpenerDirector.open", side_effect=http.client.IncompleteRead(b"ab", 10)):
with self.assertRaises(ConnectionError): with self.assertRaises(ConnectionError):
self.jira.get("/x") self.jira.get("/x")

View File

@ -242,3 +242,12 @@ class FetchAttachmentTest(unittest.TestCase):
self.assertEqual(out["status"], "failed") self.assertEqual(out["status"], "failed")
self.assertFalse(out["retryable"]) self.assertFalse(out["retryable"])
self.assertTrue(out["error"].startswith("хаб: "), out["error"]) self.assertTrue(out["error"].startswith("хаб: "), out["error"])
def test_fetch_unknown_size_cut_body_retryable(self): # M-1 финального ревью
a = self.fake.add_attachment("AURORA-1650", "a.json", b"x" * 40)
self.fake.hide_size.add(a["id"])
self.fake.lying_length.add(a["id"])
out = self.fetch(a)
self.assertEqual(out["status"], "failed")
self.assertTrue(out["retryable"])
self.assertEqual(self.hub.uploaded, [])