Агент v11: тип «Ошибка», ensure_attachment, устойчивое дочитывание комментариев (#31) #32

Merged
aep merged 7 commits from feat/31-bug-and-attachments into main 2026-10-16 09:10:20 +03:00
7 changed files with 252 additions and 4 deletions
Showing only changes of commit d75c1a5567 - Show all commits

View File

@ -11,7 +11,7 @@ Jira и хаба. Спека: `../jira-hub/openspec/changes/bridge-core/`, пл
| HTTP | `Http`, `HttpError`, `JiraClient`, `HubClient`, `multipart_file`, `wire_filename` | запросы; `Http.send` — запрос с готовым телом (JSON-вызов `call` идёт через него); `JiraClient` считает вызовы, повторяет 429/503 по `Retry-After` (до 5 запросов, не более 4 ожиданий), хранит время последнего вызова; `JiraClient.upload` — вложение `multipart/form-data` (таймаут не меньше 120 с); `HubClient.file` — файл вложения у хаба (base64 в JSON) | `urllib` |
| Папка | `FolderTransport` | транспорт `folder:` к хабу через общую папку: `req/<id>.json` → реле хаба → `resp/<id>.json`; ошибки как у `Http`; `HubClient` делегирует ему `call` (спека `jira-hub/openspec/changes/folder-transport/`) | `os`, `uuid` |
| Журнал | `Journal`, `_parse_at` | дозапись `journal.jsonl`: `started` → `finished` → `acked`; восстановление; `prune` (из CLI — под блокировкой журнала) | файловая система |
| Исполнитель | `Executor` и `do_<тип>` | по одному методу на тип команды: читает Jira, пишет только разницу; `refresh` читает и отправляет состояние в хаб | `JiraClient`, `HubClient` (только `refresh`), настройки хаба |
| Исполнитель | `Executor` и `do_<тип>` | по одному методу на тип команды: читает Jira, пишет только разницу; `refresh` читает и отправляет состояние в хаб; `ensure_attachment` берёт файл вложения у хаба (`hub_file`: sha256 и размер, повреждение — `HubError` с повтором) | `JiraClient`, `HubClient` (`refresh` и файл в `ensure_attachment`), настройки хаба |
| Самообновление | `read_update_state`, `write_update_state`, `Updater`, `launcher_version` | `update.json`; после `claim` без команды — проверка `agent_release`, скачивание, sha256, компиляция, `selftest`, `staged`; подтверждение `ok` новой версией; без запускателя — только подсказка в лог | `HubClient`, `subprocess` |
| Цикл | `Runner` | claim, пауза до команды, журнал, выполнение, отчёт до подтверждения, восстановление при старте; после `claim` без команды спрашивает `Updater`, готова версия — выход с `EXIT_UPDATE` (75) | `HubClient`, `JiraClient`, `Journal`, `Executor` |
| CLI | `cmd_run`, `cmd_check`, `cmd_selftest`, `cmd_prune`, `main` | команды `run`, `check`, `selftest`, `prune` (`run` и `prune` берут `acquire_lock` на `<журнал>.lock`); обработка сигналов | `Runner`, `Journal` |

View File

@ -20,7 +20,7 @@
выполнение → отчёт), `Updater` (самообновление: `update.json`, скачивание, `selftest`, выход 75), CLI (`cmd_check`,
`cmd_selftest`, `cmd_run`, `cmd_prune`, `main`).
- `src/run.py` — запускатель: дочерний `agent.py run`, подмена версии, пробный период, откат, перезапуск; `agent.py` не импортирует.
- Границы: `Executor` знает Jira, но не знает хаб (кроме `refresh`, где хаб нужен для `known_keys` и `state`).
- Границы: `Executor` знает Jira, но не знает хаб (кроме `refresh`, где хаб нужен для `known_keys` и `state`, и `ensure_attachment`, где у хаба берётся файл вложения).
`Runner` знает и хаб, и Jira, и журнал. Журнал не знает о Jira.
- `src/agent.example.json` — шаблон настроек, `README.md` — эксплуатация.
- Подробнее: `ARCHITECTURE.md`.
@ -39,7 +39,7 @@
- `tests/_common.py` — общая настройка: путь к `src`, `SETTINGS` (как их отдаёт `/agent/settings`), `jira_cfg()`, `no_sleep()`.
Паузы в тестах отключаются через `ZERO_PACING` и `no_sleep`.
- Тесты разбиты по областям, а не по типам команд: `test_executor_fields.py` (`ensure_issue`, `ensure_fields`, `ensure_comment`),
`test_executor_links.py` (`ensure_status`, `ensure_link`, `ensure_sprint`), `test_refresh.py` (`refresh`),
`test_executor_links.py` (`ensure_status`, `ensure_link`, `ensure_sprint`), `test_executor_attachments.py` (`ensure_attachment`), `test_refresh.py` (`refresh`),
`test_runner.py` (цикл и восстановление), `test_journal.py` (журнал), `test_clients.py` (HTTP и 429),
`test_update.py` (самообновление агента), `test_launcher.py` (запускатель на поддельных агентах),
`test_self_update_e2e.py` (`run.py` + настоящий `agent.py` + фейки).

View File

@ -171,6 +171,7 @@ python3 agent.py prune
| `ensure_status` | идёт по пути статусов, на каждом шаге выбирает переход; нет перехода — `failed` со списком доступных |
| `ensure_link` | создаёт связь; конфликт без `replace` — `failed`; с `replace` сначала удаляет лишнее |
| `ensure_sprint` | переносит в спринт доски (активные и будущие) или в бэклог |
| `ensure_attachment` | ищет у задачи вложение с тем же именем и размером — есть: ничего не делает; нет: берёт файл у хаба (`/agent/files/{sha256}`), сверяет sha256 и размер и загружает в Jira (`multipart/form-data`); файл удалён с хаба — `failed` «загрузите заново»; повреждённый файл — повтор, как обрыв связи с хабом. Содержимое файла в журнал и лог не пишется |
| `refresh` | только читает Jira и отправляет состояние задач в хаб; ничего не пишет |
Правила работы:

View File

@ -549,6 +549,9 @@ class HubError(Exception):
self.retryable = isinstance(cause, ConnectionError) or (isinstance(cause, HttpError) and cause.code >= 500)
FILE_GONE = "файл удалён с хаба, загрузите заново"
class Executor:
def __init__(self, jira: JiraClient, settings: dict, hub: Optional[HubClient] = None):
self.jira, self.s, self.hub = jira, settings, hub
@ -868,6 +871,50 @@ class Executor:
self.jira.post(AGILE + "/sprint/%s/issue" % sp["id"], {"issues": [key]})
return self._out("done", {"sprint": want, "from": cur})
# --- ensure_attachment -----------------------------------------------------
def hub_file(self, sha: str, size: int) -> Optional[bytes]:
"""Файл вложения у хаба. None — хаб его не знает (404). Повреждённый или нечитаемый ответ — HubError с повтором,
как обрыв связи с хабом. Содержимое файла в текст ошибки не попадает."""
try:
got = self.hub.file(sha)
except HttpError as e:
if e.code == 404:
return None
if 200 <= e.code < 300: # ответ не JSON: в тексте Http — начало тела, то есть содержимого
raise HubError(ConnectionError("ответ на файл %s не JSON" % sha)) from None
raise HubError(e) from None
except ConnectionError as e:
raise HubError(e) from None
content = got.get("content") if isinstance(got, dict) else None
data = None
if isinstance(content, str):
try:
data = base64.b64decode(content, validate=True)
except ValueError: # binascii.Error — подкласс ValueError
data = None
if data is None or len(data) != size or hashlib.sha256(data).hexdigest() != sha:
raise HubError(ConnectionError("файл %s пришёл повреждённым: sha256 или размер не сходятся" % sha))
return data
def do_ensure_attachment(self, p: dict, cmd: dict) -> dict:
key, sha, filename, size = p["key"], p["file"], p["filename"], p.get("size")
if size is None: # хаб не нашёл файл при выдаче
return self._out("failed", error=FILE_GONE)
names = {filename, wire_filename(filename)} # Jira могла сохранить имя в «браузерной» форме (R-23)
issue = self.jira.get(API + "/issue/%s" % key, fields="attachment")
for a in (issue.get("fields") or {}).get("attachment") or []:
if a.get("filename") in names and a.get("size") == size:
return self._out("done", {"attachment_id": str(a.get("id")), "existed": True})
data = self.hub_file(sha, size)
if data is None:
return self._out("failed", error=FILE_GONE)
log(" вложение «%s» (%d байт) → %s" % (filename, size, key))
created = self.jira.upload(key, filename, data)
first = created[0] if isinstance(created, list) and created and isinstance(created[0], dict) else {}
if not first.get("id"):
return self._out("failed", error="Jira не вернула id вложения в ответе на загрузку")
return self._out("done", {"attachment_id": str(first["id"]), "created": True})
# --- refresh ---------------------------------------------------------------
STATE_FIELDS = ["summary", "description", "status", "priority", "assignee", "reporter", "duedate", "labels",
"issuetype", "created", "updated", "issuelinks", "comment"]

View File

@ -9,7 +9,7 @@ if SRC not in sys.path:
import agent # noqa: E402
ZERO_PACING = {"ensure_issue": 0, "ensure_fields": 0, "ensure_comment": 0, "ensure_status": 0, "ensure_link": 0,
"ensure_sprint": 0, "refresh": 0}
"ensure_sprint": 0, "ensure_attachment": 0, "refresh": 0}
SETTINGS = {
"base_jql": "project = AURORA AND labels = AWPCA",

View File

@ -0,0 +1,141 @@
import hashlib
import unittest
from _common import SETTINGS, agent, jira_cfg, no_sleep
from fake_hub import FakeHub
from fake_jira import FakeJira
SHA_ABC = hashlib.sha256(b"abc").hexdigest()
class EnsureAttachmentTest(unittest.TestCase):
def setUp(self):
self.fake = FakeJira()
self.hub = FakeHub(SETTINGS)
cfg = jira_cfg(self.fake, hub_url=self.hub.base_url, hub_token=self.hub.token)
self.jira = agent.JiraClient(cfg)
self.jira.sleep = no_sleep
self.ex = agent.Executor(self.jira, dict(SETTINGS), agent.HubClient(cfg))
self.ex.sleep = no_sleep
self.fake.add("AURORA-1")
def tearDown(self):
self.fake.stop()
self.hub.stop()
def run_cmd(self, **params):
return self.ex.run({"id": 1, "type": "ensure_attachment", "params": params})
def test_upload_cyrillic_name(self):
data = bytes(range(256)) * 8
sha = self.hub.put_file(data)
name = "ОСВ сч. 60 за 2 кв. 2026.xlsx"
out = self.run_cmd(key="AURORA-1", file=sha, filename=name, size=len(data))
self.assertEqual(out["status"], "done", out)
a = self.fake.attachments["AURORA-1"][0]
self.assertEqual(out["result"], {"attachment_id": a["id"], "created": True})
self.assertEqual((a["filename"], a["size"], a["data"]), (name, len(data), data))
self.assertEqual(self.fake.requests, [("GET", "/rest/api/2/issue/AURORA-1"),
("POST", "/rest/api/2/issue/AURORA-1/attachments")])
self.assertEqual(self.fake.queries[0].get("fields"), "attachment")
self.assertEqual(self.hub.file_requests, 1)
self.assertEqual(out["jira_calls"], 2)
def test_existing_same_name_and_size(self):
a = self.fake.add_attachment("AURORA-1", "ОСВ 60.xlsx", b"xyz") # другое содержимое, тот же размер
out = self.run_cmd(key="AURORA-1", file=SHA_ABC, filename="ОСВ 60.xlsx", size=3)
self.assertEqual(out["status"], "done")
self.assertEqual(out["result"], {"attachment_id": a["id"], "existed": True})
self.assertEqual(out["jira_calls"], 1)
self.assertEqual(self.hub.file_requests, 0) # файл у хаба не запрашивается
self.assertEqual(len(self.fake.attachments["AURORA-1"]), 1)
def test_same_name_other_size_uploads_second(self):
old = self.fake.add_attachment("AURORA-1", "ОСВ 60.xlsx", "старое содержимое".encode("utf-8"))
sha = self.hub.put_file(b"abc")
out = self.run_cmd(key="AURORA-1", file=sha, filename="ОСВ 60.xlsx", size=3)
self.assertEqual(out["status"], "done")
self.assertTrue(out["result"]["created"])
self.assertNotEqual(out["result"]["attachment_id"], old["id"])
self.assertEqual([x["filename"] for x in self.fake.attachments["AURORA-1"]], ["ОСВ 60.xlsx", "ОСВ 60.xlsx"])
def test_existing_under_wire_name(self):
name = 'итог "кв.2".txt'
a = self.fake.add_attachment("AURORA-1", agent.wire_filename(name), b"abc") # так Jira сохранила прошлую загрузку
out = self.run_cmd(key="AURORA-1", file=SHA_ABC, filename=name, size=3)
self.assertEqual(out["result"], {"attachment_id": a["id"], "existed": True})
self.assertEqual(self.hub.file_requests, 0)
def test_size_null_fails_without_calls(self):
out = self.run_cmd(key="AURORA-1", file=SHA_ABC, filename="a.txt", size=None)
self.assertEqual(out["status"], "failed")
self.assertEqual(out["error"], "файл удалён с хаба, загрузите заново")
self.assertNotIn("retryable", out)
self.assertEqual(self.fake.requests, [])
self.assertEqual(self.hub.file_requests, 0)
def test_hub_404_file_gone(self):
out = self.run_cmd(key="AURORA-1", file=SHA_ABC, filename="a.txt", size=3) # в хранилище хаба файла нет
self.assertEqual(out["status"], "failed")
self.assertEqual(out["error"], "файл удалён с хаба, загрузите заново")
self.assertNotIn("retryable", out)
self.assertEqual(self.hub.file_requests, 1)
self.assertFalse(any(m == "POST" for m, _ in self.fake.requests))
def test_corrupted_sha_is_retryable_hub_failure(self):
self.hub.files[SHA_ABC] = b"xyz" # размер тот же, содержимое другое
out = self.run_cmd(key="AURORA-1", file=SHA_ABC, filename="a.txt", size=3)
self.assertEqual(out["status"], "failed")
self.assertTrue(out["retryable"])
self.assertEqual(out["peer"], "хабом")
self.assertTrue(out["error"].startswith("хаб: "), out["error"])
self.assertNotIn("AURORA-1", self.fake.attachments)
self.assertFalse(any(m == "POST" for m, _ in self.fake.requests))
def test_length_mismatch_is_retryable_hub_failure(self):
self.hub.put_file(b"abc")
out = self.run_cmd(key="AURORA-1", file=SHA_ABC, filename="a.txt", size=4)
self.assertEqual(out["status"], "failed")
self.assertTrue(out["retryable"])
self.assertEqual(out["peer"], "хабом")
self.assertNotIn("AURORA-1", self.fake.attachments)
def test_hub_non_json_file_no_content_in_error(self):
def broken(sha):
raise agent.HttpError(200, 'ответ не JSON: Unterminated string; начало: {"file": "x", "content": "U0VDUkVULUNPTlRFTlQ')
self.ex.hub.file = broken
out = self.run_cmd(key="AURORA-1", file=SHA_ABC, filename="a.txt", size=3)
self.assertEqual(out["status"], "failed")
self.assertTrue(out["retryable"])
self.assertEqual(out["peer"], "хабом")
self.assertNotIn("U0VDUkVU", out["error"])
self.assertNotIn("начало", out["error"])
def test_hub_4xx_other_than_404_not_retryable(self):
self.ex.hub = agent.HubClient({"hub_url": self.hub.base_url, "hub_token": "wrong-token", "timeout": 5})
out = self.run_cmd(key="AURORA-1", file=SHA_ABC, filename="a.txt", size=3)
self.assertEqual(out["status"], "failed")
self.assertTrue(out["error"].startswith("хаб: HTTP 401"), out["error"])
self.assertFalse(out["retryable"])
def test_jira_413_failed_with_code_and_text(self):
self.fake.attachment_limit = 2
sha = self.hub.put_file(b"abc")
out = self.run_cmd(key="AURORA-1", file=sha, filename="a.txt", size=3)
self.assertEqual(out["status"], "failed")
self.assertTrue(out["error"].startswith("HTTP 413"), out["error"])
self.assertIn("Размер вложения превышает предел", out["error"])
self.assertFalse(out["retryable"])
def test_upload_response_without_id(self):
self.fake.bad_attachment_response = True
sha = self.hub.put_file(b"abc")
out = self.run_cmd(key="AURORA-1", file=sha, filename="a.txt", size=3)
self.assertEqual(out["status"], "failed")
self.assertEqual(out["error"], "Jira не вернула id вложения в ответе на загрузку")
self.assertNotIn("retryable", out)
if __name__ == "__main__":
unittest.main()

View File

@ -1,3 +1,6 @@
import base64
import hashlib
import json
import os
import tempfile
import threading
@ -580,6 +583,62 @@ class RunnerTest(unittest.TestCase):
self.assertTrue(any("обрыв связи с хабом, повтор 1/3" in l for l in logs), logs)
self.assertNotIn("peer", r)
def test_attachment_upload_dropped_no_duplicate(self):
sha = self.hub.put_file(b"abc")
self.fake.drop_next_post_attachment = True # Jira сохранила вложение, ответ потерян
self.hub.enqueue("ensure_attachment", {"key": "AURORA-1", "file": sha, "filename": "ОСВ 60.xlsx"})
self.runner.loop()
r = self.hub.results[1]
self.assertEqual(r["status"], "done", r)
self.assertTrue(r["result"]["existed"])
self.assertEqual(len(self.fake.attachments["AURORA-1"]), 1)
self.assertEqual(r["result"]["attachment_id"], self.fake.attachments["AURORA-1"][0]["id"])
self.assertEqual(self.hub.file_requests, 1) # повтор нашёл вложение, файл второй раз не нужен
self.assertAlmostEqual(sum(self.sleeps), 30, delta=1.5)
self.assertEqual(r["jira_calls"], 2) # GET + повторный GET (оборванный POST не считается)
def test_attachment_corrupted_file_retried_as_hub_drop(self):
sha = hashlib.sha256(b"abc").hexdigest()
self.hub.files[sha] = b"xyz" # размер тот же, содержимое другое
self.hub.enqueue("ensure_attachment", {"key": "AURORA-1", "file": sha, "filename": "a.txt"})
self.runner.loop()
r = self.hub.results[1]
self.assertEqual(r["status"], "failed")
self.assertTrue(r["error"].startswith("результат неизвестен (обрыв связи с хабом), повтор безопасен: хаб: "),
r["error"])
self.assertEqual(self.hub.file_requests, 4) # первый запуск + 3 повтора
self.assertFalse(any(m == "POST" for m, _ in self.fake.requests))
self.assertEqual(self.fake.attachments, {})
def test_attachment_file_gone_at_claim(self):
self.hub.enqueue("ensure_attachment", {"key": "AURORA-1", "file": "0" * 64, "filename": "a.txt"})
self.runner.loop()
r = self.hub.results[1]
self.assertEqual((r["status"], r["error"]), ("failed", "файл удалён с хаба, загрузите заново"))
self.assertEqual(r["jira_calls"], 0)
self.assertEqual(self.fake.requests, [])
def test_attachment_content_not_in_journal_or_log(self):
data = b"SECRET-CONTENT-" + bytes(range(256)) * 4
sha = self.hub.put_file(data)
secret_b64 = base64.b64encode(data).decode("ascii")
self.hub.enqueue("ensure_attachment", {"key": "AURORA-1", "file": sha, "filename": "отчёт.bin"})
logs = []
orig = agent.log
agent.log = logs.append
try:
self.runner.loop()
finally:
agent.log = orig
self.assertEqual(self.hub.results[1]["status"], "done")
with open(self.journal.path, encoding="utf-8") as f:
text = f.read()
text += "\n".join(logs) + json.dumps(self.hub.results[1], ensure_ascii=False)
for needle in ("SECRET-CONTENT", secret_b64[:40], secret_b64[-40:]):
self.assertNotIn(needle, text)
self.assertTrue(any(sha in l and "отчёт.bin" in l for l in logs), logs) # file и filename — в логе
self.assertTrue(any(str(len(data)) in l for l in logs), logs) # размер — в логе
if __name__ == "__main__":
unittest.main()