# bug-and-attachments — агент v11: тип «Ошибка», вложения, устойчивое дочитывание комментариев — план > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** агент v11 заводит задачи типом из `issue_types.<тип>` (в том числе `bug` — «Ошибка») с понятной ошибкой конфигурации, выполняет новую команду `ensure_attachment` (вложение с тем же именем и размером есть — ничего; иначе файл у хаба → проверка sha256 и длины → multipart-загрузка в Jira) и не роняет `refresh` на комментариях без `id` и на 403/404 при их дочитывании (M-1, M-2 из aep/jira-agent#30). **Architecture:** всё в `src/agent.py`. HTTP-слой: `Http.send` — запрос с готовым телом (JSON-вызов `Http.call` идёт через него), `JiraClient._retry` — общий повтор 429/503, `JiraClient.upload` — multipart через `multipart_file` (граница `uuid4().hex`, `filename="…"` и `filename*=UTF-8''…`), `HubClient.file` — `GET /agent/files/{sha256}` (JSON с base64 проходит и по HTTP, и через `FolderTransport` без изменений транспорта). Исполнитель: `issue_type_ref` бросает `ConfigGap`, `do_ensure_attachment` + `hub_file`; повреждённый файл — `HubError` с `retryable = True`, и `Runner.execute` повторяет команду как при обрыве связи с хабом. `comments_of` отбрасывает комментарии без `id` и возвращает `None` на 403/404 — `do_refresh` тогда не кладёт `comments`. **Tech Stack:** Python 3.9.6 (`/usr/bin/python3`), только стандартная библиотека; тесты — `unittest` на фейках `tests/fake_jira.py`, `tests/fake_hub.py`, `tests/fake_relay.py`. **Spec:** `../jira-hub/openspec/changes/bug-and-attachments/` (worktree спеки: `/Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/bug-attach/openspec/changes/bug-and-attachments/`) — `specs/agent/spec.md` (MODIFIED «Чтение состояния», ADDED «Тип задачи и вложения» — основное), `specs/commands/spec.md` (таблица команд, «Проверки ensure_issue с типом bug и ensure_attachment при постановке» — `size` дописывает хаб при выдаче), `specs/hub-api/spec.md` (`GET /agent/files/{sha256}` → `{file, size, content}`, 404 `no_file`), `design.md` (раздел «Агент v11», «Выкатка», «Risks»). Действующая спека агента — `../jira-hub/openspec/specs/agent/spec.md`. Решения агентской части — `openspec/changes/agent-v11-bug-attachments/design.md` (создаётся в Task 5). **Worktree:** `/Users/andrey.epifancev/projects/awp-ca/jira-agent/.wt/bug-attach`, ветка `feat/31-bug-and-attachments` (aep/jira-agent#31). Все команды — из корня worktree; в основные checkout'ы `jira-agent` и `jira-hub` не переходить. ## Global Constraints - Python ≥ 3.9 (на виртуалке 3.9.6, тесты — `/usr/bin/python3`), только стандартная библиотека; синтаксис 3.10+ (`X | Y` в аннотациях функций, `match`) запрещён — `tests/test_py39.py`. - Тесты: `/usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t .` — зелёным считается только весь набор. - `AGENT_VERSION` SHALL быть 11. - Тип для `ensure_issue` — из `issue_types.`: `{id}`, если `id` задан, иначе `{name}`; нет записи или в ней нет ни `id`, ни `name` — ошибка конфигурации «нет настройки issue_types.», `failed` без вызова Jira. - `ensure_attachment {key, file, filename, size}` (`size` дописан хабом при выдаче): `size = null` — `failed` «файл удалён с хаба, загрузите заново» без вызова Jira; `GET /rest/api/2/issue/{key}?fields=attachment`, совпадение `filename` и `size` — `done` с `attachment_id`, файл у хаба не запрашивается; файл — `GET /agent/files/{file}`, sha256 и длина декодированного `content` SHALL совпасть с `file` и `size`, иначе временный сбой (повтор, как обрыв связи с хабом); 404 — `failed` «файл удалён с хаба, загрузите заново»; `POST /rest/api/2/issue/{key}/attachments`, `multipart/form-data` с одной частью `file`, имя файла — `filename` в UTF-8 (`filename="…"` и `filename*=UTF-8''…`), заголовок `X-Atlassian-Token: no-check`; результат `attachment_id` из ответа; ответ Jira 4xx (в том числе 413) — `failed` с кодом и текстом Jira. - Граница multipart — `uuid4().hex`; у части `Content-Type: application/octet-stream`; таймаут загрузки — `max(timeout, 120)` с; ответ Jira — список вложений, берётся `id` первого (design «Агент v11»). - Содержимое файла MUST NOT попадать в журнал и лог (только `file`, `filename`, размер). - Комментарий без `id` или с пустым `id` — отбросить с записью в лог (ключ задачи); 403 или 404 при дочитывании комментариев — задача без поля `comments` с записью в лог, `refresh` продолжается. - Коммиты на русском с `(#31)`, трейлер `Co-Authored-By: Claude Opus 5.5 `. ## Review Focus 1. Jira приняла файл, но соединение оборвалось до ответа — повтор через проверку находит вложение по имени и размеру, второго вложения нет, файл у хаба второй раз не берётся (Task 4, `test_attachment_upload_dropped_no_duplicate`). 2. Имя файла с кавычкой или переводом строки — multipart не ломается (одна часть, тело цело), а повтор узнаёт уже загруженное вложение и под «браузерной» формой имени (Task 3, `test_upload_filename_with_quote_and_newline`; Task 4, `test_existing_under_wire_name`). 3. Хаб ответил 2xx, но тело не JSON (обрезанный ответ) — временный сбой; начало тела (то есть содержимого файла в base64) не попадает в текст ошибки, журнал и лог (Task 4, `test_hub_non_json_file_no_content_in_error`). 4. Jira ответила 429 на загрузку — повтор по `Retry-After` тем же правилом, что у остальных вызовов; одно вложение (Task 3, `test_upload_retries_429`). 5. Ответ Jira на загрузку — не список или без `id` — понятный `failed`, а не «внутренняя ошибка агента: KeyError» (Task 4, `test_upload_response_without_id`). --- ### Task 1: Устойчивое дочитывание комментариев в `refresh` (M-1, M-2) **Модель исполнителя:** haiku (готовый код). **Files:** - Modify: `tests/fake_jira.py` (`comment_denied` — код ответа на `GET /issue/{key}/comment`) - Modify: `src/agent.py` — `Executor.comments_of` (стр. 903–918), цикл сборки состояния в `do_refresh` (стр. 969–973) - Modify: `README.md` — пункт 4 «Особенности `refresh`» раздела «Известные ограничения / эксплуатация» - Test: `tests/test_refresh.py` **Interfaces:** - Consumes: — - Produces: `Executor.comments_of(raw: dict) -> Optional[List[dict]]` (`None` — комментарии не дочитаны из-за 403/404, поле `comments` в состояние не кладётся); фейк `FakeJira.comment_denied: Dict[str, int]`. - [ ] **Step 1: Фейк Jira — отказ в чтении комментариев** В `tests/fake_jira.py`, в `FakeJira.__init__` после строки `self.search_comment_cap = None …` добавить: ```python self.comment_denied = {} # ключ → код ответа на GET /issue/{key}/comment (403 — нет прав, 404 — нет задачи) ``` В `route`, в обработчике `^/rest/api/2/issue/([^/]+)/comment$` заменить начало ```python if m: key = m.group(1) self.issues[key] ``` на ```python if m: key = m.group(1) if method == "GET" and key in self.comment_denied: return self.comment_denied[key], {"errorMessages": ["Нет прав на просмотр комментариев"]} self.issues[key] ``` - [ ] **Step 2: Падающие тесты** В `tests/test_refresh.py` после `test_comments_field_missing` добавить: ```python def test_comment_without_id_dropped(self): self.fake.add("AURORA-1") self.fake.add_comment("AURORA-1", "нормальный") self.fake.add_comment("AURORA-1", "второй")["id"] = None self.fake.add_comment("AURORA-1", "третий")["id"] = "" logs = [] orig = agent.log agent.log = logs.append try: out = self.ex.run({"id": 10, "type": "refresh", "params": {"keys": ["AURORA-1"]}}) finally: agent.log = orig self.assertEqual(out["status"], "done") c = self.hub.states[0]["issues"][0]["comments"] self.assertEqual([x["body"] for x in c], ["нормальный"]) self.assertEqual(len([l for l in logs if "AURORA-1" in l and "комментарий без id" in l]), 2, logs) def test_comments_read_denied_keeps_refresh(self): for key in ("AURORA-1", "AURORA-2", "AURORA-3"): self.fake.add(key) for n in range(3): self.fake.add_comment(key, "%s-%d" % (key, n)) self.fake.search_comment_cap = 1 # search отдаёт 1 из 3 — нужно дочитывание self.fake.comment_denied = {"AURORA-1": 403, "AURORA-2": 404} logs = [] orig = agent.log agent.log = logs.append try: out = self.ex.run({"id": 11, "type": "refresh", "params": {"keys": ["AURORA-1", "AURORA-2", "AURORA-3"]}}) finally: agent.log = orig self.assertEqual(out["status"], "done") by_key = {i["key"]: i for i in self.hub.states[0]["issues"]} self.assertNotIn("comments", by_key["AURORA-1"]) self.assertNotIn("comments", by_key["AURORA-2"]) self.assertEqual([x["body"] for x in by_key["AURORA-3"]["comments"]], ["AURORA-3-0", "AURORA-3-1", "AURORA-3-2"]) self.assertTrue(any("AURORA-1" in l and "HTTP 403" in l for l in logs), logs) self.assertTrue(any("AURORA-2" in l and "HTTP 404" in l for l in logs), logs) def test_comments_read_5xx_still_fails_refresh(self): self.fake.add("AURORA-1") for n in range(3): self.fake.add_comment("AURORA-1", "к%d" % n) self.fake.search_comment_cap = 1 self.fake.comment_denied = {"AURORA-1": 500} out = self.ex.run({"id": 12, "type": "refresh", "params": {"keys": ["AURORA-1"]}}) self.assertEqual(out["status"], "failed") self.assertTrue(out["retryable"]) # 5xx — по-прежнему повтор через проверку (R-10) self.assertEqual(self.hub.states, []) ``` Run: `/usr/bin/python3 -m unittest tests.test_refresh -v` Expected: FAIL — `test_comment_without_id_dropped` (в состоянии три комментария, id `"None"` и `""`), `test_comments_read_denied_keeps_refresh` (`status` = `failed`, `HTTP 403`); `test_comments_read_5xx_still_fails_refresh` — PASS уже сейчас. - [ ] **Step 3: Реализация** В `src/agent.py` заменить метод `comments_of` целиком: ```python def comments_of(self, raw: dict) -> Optional[List[dict]]: """Комментарии задачи из ответа search. Если Jira отдала не все (len < total) — дочитать постранично через /issue/{key}/comment и взять полный список вместо частичного. Комментарий без id отбрасывается (M-1 #30); 403/404 при дочитывании — None: задача уходит без comments, хаб оставит прежние (M-2 #30).""" key = raw["key"] block = (raw.get("fields") or {}).get("comment") or {} got = list(block.get("comments") or []) total = block.get("total") if total is not None and len(got) < int(total): got, start = [], 0 try: while True: page = self.jira.get(API + "/issue/%s/comment" % key, startAt=start, maxResults=100) part = page.get("comments") or [] got += part start += len(part) if not part or page.get("total") is None or start >= int(page["total"]): break except HttpError as e: if e.code not in (403, 404): raise log("refresh: %s — комментарии не дочитаны (HTTP %d), задача уходит без comments" % (key, e.code)) return None out = [] for c in got: if c.get("id") is None or str(c.get("id")) == "": log("refresh: %s — комментарий без id пропущен" % key) continue out.append(self.comment_state(c)) return out ``` В `do_refresh` заменить строку ```python st["comments"] = self.comments_of(r) ``` на ```python comments = self.comments_of(r) if comments is not None: # None — нет доступа к комментариям: поле не передаём (M-2 #30) st["comments"] = comments ``` - [ ] **Step 4: Тесты проходят** Run: `/usr/bin/python3 -m unittest tests.test_refresh -v` Expected: PASS (все тесты файла, включая прежние `test_comments_*`). - [ ] **Step 5: README** В `README.md`, пункт `4. **Особенности \`refresh\`.**`, после предложения, которое заканчивается `` агент дочитывает их через `/issue/{key}/comment`. ``, дописать строки того же отступа (3 пробела): ```markdown С v11 комментарий без `id` пропускается (строка в логе с ключом задачи), а если Jira не дала дочитать комментарии задачи (403 или 404), задача уходит без `comments` — хаб оставляет прежние; `refresh` продолжается. ``` - [ ] **Step 6: Commit** ```bash git add src/agent.py tests/fake_jira.py tests/test_refresh.py README.md git commit -m "fix(agent): refresh не падает на комментариях без id и на 403/404 при дочитывании (M-1, M-2 #30, #31)" -m "Co-Authored-By: Claude Opus 5.5 " ``` --- ### Task 2: Тип задачи из настроек хаба, тип `bug` **Модель исполнителя:** haiku (готовый код). **Files:** - Modify: `tests/_common.py` (`SETTINGS["issue_types"]` — `bug`) - Modify: `src/agent.py` — `Executor.issue_type_ref` (стр. 574–576), начало `do_ensure_issue` (стр. 595–609) - Modify: `README.md` — строка `ensure_issue` таблицы «Что делает агент с командами» - Test: `tests/test_executor_fields.py` **Interfaces:** - Consumes: — - Produces: `Executor.issue_type_ref(kind: str) -> dict` — `{"id": str}` или `{"name": str}`, иначе `ConfigGap` «нет настройки issue_types.»; `SETTINGS["issue_types"]["bug"] = {"name": "Ошибка", "id": None}`. - [ ] **Step 1: Настройки тестов** В `tests/_common.py` заменить строку `"issue_types": …` на: ```python "issue_types": {"task": {"name": "Задача", "id": "10100"}, "epic": {"name": "Фича", "id": "12300"}, "bug": {"name": "Ошибка", "id": None}}, ``` - [ ] **Step 2: Падающие тесты** В `tests/test_executor_fields.py` после `test_ensure_issue_priority_mapped` добавить: ```python def test_ensure_issue_bug_by_name(self): self.assertEqual(self.ex.issue_type_ref("bug"), {"name": "Ошибка"}) # id нет — по имени self.assertEqual(self.ex.issue_type_ref("task"), {"id": "10100"}) # id есть — по id out = self.run_cmd("ensure_issue", temp_key="new:1", issue_type="bug", summary="ОСВ 60 не распознана", labels=["AWPCA"]) self.assertEqual(out["status"], "done") self.assertTrue(out["result"]["created"]) self.assertEqual(self.fake.issues[out["result"]["key"]]["issuetype"], "Ошибка") def test_ensure_issue_type_not_configured_no_jira_calls(self): self.ex.s = dict(SETTINGS, issue_types={"task": {"name": "Задача", "id": "10100"}}) out = self.run_cmd("ensure_issue", temp_key="new:1", issue_type="bug", summary="x") self.assertEqual(out["status"], "failed") self.assertEqual(out["error"], "нет настройки issue_types.bug") self.assertNotIn("retryable", out) self.assertEqual(self.fake.requests, []) # ни поиска дубля, ни создания def test_issue_type_entry_without_name_and_id(self): for entry in ({}, {"name": None, "id": None}, {"name": "", "id": ""}, None, "Ошибка"): self.ex.s = dict(SETTINGS, issue_types={"bug": entry}) with self.assertRaises(agent.ConfigGap) as cm: self.ex.issue_type_ref("bug") self.assertEqual(str(cm.exception), "нет настройки issue_types.bug") self.ex.s = dict(SETTINGS) del self.ex.s["issue_types"] with self.assertRaises(agent.ConfigGap): self.ex.issue_type_ref("task") ``` Run: `/usr/bin/python3 -m unittest tests.test_executor_fields -v` Expected: FAIL — `test_ensure_issue_type_not_configured_no_jira_calls` (сейчас уходит поиск и `POST` с `{"name": None}`), `test_issue_type_entry_without_name_and_id` (`ConfigGap` не брошен). - [ ] **Step 3: Реализация** В `src/agent.py` заменить `issue_type_ref` целиком: ```python def issue_type_ref(self, kind: str) -> dict: """Тип задачи Jira из issue_types.: {id}, если id задан, иначе {name}; нет ни того, ни другого — ошибка конфигурации, а не {"name": None}, на которое Jira ответит 400 без понятной причины (спека agent v11).""" it = (self.s.get("issue_types") or {}).get(kind) if isinstance(it, dict) and it.get("id"): return {"id": str(it["id"])} if isinstance(it, dict) and it.get("name"): return {"name": it["name"]} raise ConfigGap("нет настройки issue_types.%s" % kind) ``` В `do_ensure_issue` первые строки метода ```python def do_ensure_issue(self, p: dict, cmd: dict) -> dict: summary = (p["summary"] or "").strip() ``` заменить на ```python def do_ensure_issue(self, p: dict, cmd: dict) -> dict: issuetype = self.issue_type_ref(p["issue_type"]) # до поиска дубля: без настройки — ни одного вызова Jira summary = (p["summary"] or "").strip() ``` и в той же функции строку ```python fields = {"project": {"key": self.s["project_key"]}, "issuetype": self.issue_type_ref(p["issue_type"]), "summary": p["summary"]} ``` на ```python fields = {"project": {"key": self.s["project_key"]}, "issuetype": issuetype, "summary": p["summary"]} ``` - [ ] **Step 4: Тесты проходят** Run: `/usr/bin/python3 -m unittest tests.test_executor_fields -v` Expected: PASS. - [ ] **Step 5: README** В `README.md`, таблица раздела «Что делает агент с командами», строку `ensure_issue` заменить на: ```markdown | `ensure_issue` | ищет задачу своей темы (`reporter = currentUser()`, за 7 дней), иначе создаёт с типом из `issue_types` хаба (`task`, `epic`, `bug`; нет настройки типа — `failed` «нет настройки issue_types.<тип>» без вызова Jira); возвращает `key` | ``` - [ ] **Step 6: Весь набор** Run: `/usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t .` Expected: `OK` (новая запись `bug` в `SETTINGS` не меняет прежних тестов). - [ ] **Step 7: Commit** ```bash git add src/agent.py tests/_common.py tests/test_executor_fields.py README.md git commit -m "feat(agent): тип задачи из issue_types с ошибкой конфигурации, тип bug (#31)" -m "Co-Authored-By: Claude Opus 5.5 " ``` --- ### Task 3: HTTP — multipart-загрузка в Jira и файл у хаба **Модель исполнителя:** haiku (готовый код). **Files:** - Modify: `src/agent.py` — импорт `typing` (стр. 34), `Http.call` (стр. 114–145), `JiraClient.call` (стр. 187–204), новые `wire_filename`, `multipart_file` (перед `class Http`), `HubClient.file` (после `known_keys`, стр. 330–331) - Modify: `tests/fake_jira.py` (разбор multipart, вложения, `POST /issue/{key}/attachments`, проверка `X-Atlassian-Token`) - Modify: `tests/fake_hub.py` (хранилище файлов, `GET /agent/files/{sha}`, `size` при выдаче `ensure_attachment`) - Modify: `ARCHITECTURE.md` — строка «HTTP» таблицы разделов - Test: `tests/test_clients.py`, `tests/test_folder.py` **Interfaces:** - Consumes: — - Produces: - `wire_filename(name: str) -> str` — имя для `filename="…"`: `"` → `%22`, `\r` → `%0D`, `\n` → `%0A`, остальное как есть. - `multipart_file(boundary: str, filename: str, data: bytes) -> bytes` — тело с одной частью `file`. - `Http.send(method: str, path: str, data: Optional[bytes], content_type: Optional[str], params=None, timeout=None) -> Any` — запрос с готовым телом, ответ — JSON (ошибки как у `Http.call`). - `JiraClient._retry(request: Callable[[], Any]) -> Any` — повтор 429/503 по `Retry-After` (до 5 запросов). - `JiraClient.upload(key: str, filename: str, data: bytes) -> Any` — `POST /rest/api/2/issue/{key}/attachments`, таймаут `max(self.timeout, 120)`; возвращает ответ Jira как есть (ожидается список вложений). - `HubClient.file(sha256: str) -> dict` — `GET /agent/files/{sha256}` → `{file, size, content}`. - Фейк Jira: `FakeJira.attachments: Dict[str, List[dict]]` (`{id, filename, size, mimeType, data}`), `add_attachment(key, filename, data, mime="application/octet-stream") -> dict`, `attachment_json(a) -> dict`, `uploads: List[dict]` (`{key, token, content_type, headers, disposition}`), `drop_next_post_attachment: bool`, `attachment_limit: Optional[int]`, `bad_attachment_response: bool`; модульные `parse_multipart(raw, content_type)`, `disposition_params(value)`. - Фейк хаба: `FakeHub.files: Dict[str, bytes]`, `put_file(data: bytes) -> str` (sha256), `file_requests: int`; при выдаче `ensure_attachment` (`claim` и `GET /agent/commands/{id}`) к `params` добавляется `size` — длина файла или `None`, если файла нет (как хаб, `commands`). - [ ] **Step 1: Фейк Jira — вложения и multipart** В `tests/fake_jira.py`: 1. Импорт: строку `from urllib.parse import parse_qs, urlparse` заменить на ```python from urllib.parse import parse_qs, unquote, urlparse ``` 2. После `CUSTOM = (…)` добавить функции модуля: ```python def parse_multipart(raw, content_type): """Строгий разбор multipart/form-data, как commons-fileupload Jira Server с кодировкой заголовков UTF-8: [{"headers": {имя в нижнем регистре: значение}, "data": bytes}]. Нарушение формата — AssertionError (фейк ответит 400).""" m = re.search(r"boundary=([^;\s]+)", content_type) assert m, "нет boundary в Content-Type" delim = b"--" + m.group(1).encode("ascii") head, tail = delim + b"\r\n", b"\r\n" + delim + b"--\r\n" assert raw.startswith(head), "тело не начинается с границы" assert raw.endswith(tail), "нет закрывающей границы" parts = [] for chunk in raw[len(head):len(raw) - len(tail)].split(b"\r\n" + delim + b"\r\n"): hdr, sep, data = chunk.partition(b"\r\n\r\n") assert sep, "у части нет пустой строки после заголовков" headers = {} for line in hdr.decode("utf-8").split("\r\n"): name, _, value = line.partition(":") headers[name.strip().lower()] = value.strip() parts.append({"headers": headers, "data": data}) return parts def disposition_params(value): """Параметры Content-Disposition части: name; filename — как в кавычках, без percent-декодирования (так читает Jira Server); filename* — после UTF-8'' с percent-декодированием.""" out = {} m = re.search(r'(?:^|;)\s*name="([^"]*)"', value) if m: out["name"] = m.group(1) m = re.search(r'(?:^|;)\s*filename="([^"]*)"', value) if m: out["filename"] = m.group(1) m = re.search(r"(?:^|;)\s*filename\*=UTF-8''([^;\s]+)", value) if m: out["filename*"] = unquote(m.group(1), encoding="utf-8", errors="strict") return out ``` 3. В `FakeJira.__init__` после строки `self.comment_denied = {} …` (Task 1) добавить: ```python self.attachments = {} # ключ → [{id, filename, size, mimeType, data}] self.attachment_seq = 20000 self.uploads = [] # разобранные POST /attachments: {key, token, content_type, headers, disposition} self.drop_next_post_attachment = False # сохранить вложение и оборвать соединение без ответа self.attachment_limit = None # байт: больше — 413, как предел вложений Jira self.bad_attachment_response = False # ответить на загрузку пустым списком self.last_headers = {} # заголовки текущего запроса (имена в нижнем регистре) ``` 4. После метода `add_comment` добавить: ```python def add_attachment(self, key, filename, data, mime="application/octet-stream"): self.attachment_seq += 1 a = {"id": str(self.attachment_seq), "filename": filename, "size": len(data), "mimeType": mime, "data": data} self.attachments.setdefault(key, []).append(a) return a def attachment_json(self, a): return {"self": "%s/rest/api/2/attachment/%s" % (self.base_url, a["id"]), "id": a["id"], "filename": a["filename"], "size": a["size"], "mimeType": a["mimeType"]} ``` 5. В `issue_json` перед строкой `return {"key": key, "id": f["id"], "fields": out}` добавить: ```python if fields is None or "attachment" in fields: out["attachment"] = [self.attachment_json(a) for a in self.attachments.get(key, [])] ``` 6. В `handle` заменить строки ```python length = int(h.headers.get("Content-Length") or 0) body = json.loads(h.rfile.read(length).decode("utf-8")) if length else {} with self.lock: self.requests.append((method, url.path)) ``` на ```python length = int(h.headers.get("Content-Length") or 0) raw = h.rfile.read(length) if length else b"" ctype = h.headers.get("Content-Type") or "" if ctype.startswith("multipart/form-data"): body = {"_raw": raw, "_content_type": ctype} # разбирает маршрут: ошибка формата — 400 else: body = json.loads(raw.decode("utf-8")) if raw else {} with self.lock: self.last_headers = {k.lower(): v for k, v in h.headers.items()} self.requests.append((method, url.path)) ``` и после блока `if self.drop_next_post_issue and …: … return` (перед `self._send(h, code, payload)`) добавить: ```python if self.drop_next_post_attachment and method == "POST" and url.path.endswith("/attachments"): self.drop_next_post_attachment = False h.close_connection = True h.connection.shutdown(socket.SHUT_RDWR) # вложение сохранено, ответа клиент не получит return ``` 7. В `route` после обработчика `^/rest/api/2/issue/([^/]+)/comment/(\d+)$` добавить: ```python m = re.match(r"^/rest/api/2/issue/([^/]+)/attachments$", p) if m and method == "POST": key = m.group(1) self.issues[key] if self.last_headers.get("x-atlassian-token") != "no-check": return 403, {"errorMessages": ["XSRF check failed"]} # как Jira Server без заголовка parts = parse_multipart(body["_raw"], body["_content_type"]) assert len(parts) == 1, "ожидалась одна часть, пришло %d" % len(parts) part = parts[0] disp = disposition_params(part["headers"].get("content-disposition", "")) assert disp.get("name") == "file", "часть называется не file: %r" % disp self.uploads.append({"key": key, "token": self.last_headers.get("x-atlassian-token"), "content_type": body["_content_type"], "headers": part["headers"], "disposition": disp}) if self.attachment_limit is not None and len(part["data"]) > self.attachment_limit: return 413, {"errorMessages": ["Размер вложения превышает предел %d байт" % self.attachment_limit]} a = self.add_attachment(key, disp["filename"], part["data"], part["headers"].get("content-type") or "application/octet-stream") if self.bad_attachment_response: return 200, [] return 200, [self.attachment_json(a)] ``` - [ ] **Step 2: Фейк хаба — файлы** В `tests/fake_hub.py`: 1. Импорты: перед `import json` добавить ```python import base64 import hashlib ``` 2. В `FakeHub.__init__` после `self.claim_wait = 0 …` добавить: ```python self.files = {} # sha256 → содержимое (хранилище файлов вложений хаба) self.file_requests = 0 ``` 3. После метода `enqueue` добавить: ```python def put_file(self, data): sha = hashlib.sha256(data).hexdigest() self.files[sha] = data return sha def _issued(self, c): """Параметры при выдаче: как хаб, к ensure_attachment дописывается size файла (None — файла нет).""" params = dict(c["params"]) if c["type"] == "ensure_attachment": data = self.files.get(params.get("file")) params["size"] = len(data) if data is not None else None return params ``` 4. В `route`, в ветке `/agent/claim`, строку ```python return 200, {"command": {"id": cid, "type": c["type"], "params": c["params"], "pause_seconds": c["pause_seconds"]}, ``` заменить на ```python return 200, {"command": {"id": cid, "type": c["type"], "params": self._issued(c), "pause_seconds": c["pause_seconds"]}, ``` 5. В `route` ветку `^/agent/commands/(\d+)$` ```python if m: c = self.commands.get(int(m.group(1))) return (200, c) if c else (404, {"error": "not_found"}) ``` заменить на ```python if m: c = self.commands.get(int(m.group(1))) return (200, dict(c, params=self._issued(c))) if c else (404, {"error": "not_found"}) ``` 6. В `route` перед `m = re.match(r"^/agent/commands/(\d+)$", path)` добавить: ```python m = re.match(r"^/agent/files/([^/]+)$", path) if m and method == "GET": self.file_requests += 1 data = self.files.get(m.group(1)) if data is None: return 404, {"error": "no_file", "detail": "файла %s нет в хранилище" % m.group(1)} return 200, {"file": m.group(1), "size": len(data), "content": base64.b64encode(data).decode("ascii")} ``` - [ ] **Step 3: Падающие тесты клиентов** В `tests/test_clients.py` импорты заменить на: ```python import unittest import urllib.parse from _common import agent, jira_cfg, no_sleep from fake_jira import FakeJira ``` и в класс `ClientsTest` (после `test_get_and_counters`) добавить: ```python def test_multipart_file_bytes(self): body = agent.multipart_file("b0", "Отчёт.txt", b"x\r\ny") q = urllib.parse.quote("Отчёт.txt", safe="") expected = ('--b0\r\nContent-Disposition: form-data; name="file"; filename="Отчёт.txt"; ' "filename*=UTF-8''%s\r\nContent-Type: application/octet-stream\r\n\r\n" % q).encode("utf-8") \ + b"x\r\ny" + b"\r\n--b0--\r\n" self.assertEqual(body, expected) def test_wire_filename(self): self.assertEqual(agent.wire_filename('а "б"\r\n.txt'), "а %22б%22%0D%0A.txt") self.assertEqual(agent.wire_filename("ОСВ 60.xlsx"), "ОСВ 60.xlsx") def test_upload_cyrillic_name(self): self.fake.add("AURORA-1") name = "ОСВ сч. 60 за 2 кв. 2026.xlsx" data = bytes(range(256)) * 40 resp = self.jira.upload("AURORA-1", name, data) self.assertIsInstance(resp, list) a = self.fake.attachments["AURORA-1"][0] self.assertEqual((resp[0]["id"], a["filename"], a["size"], a["data"]), (a["id"], name, len(data), data)) up = self.fake.uploads[-1] self.assertEqual(up["token"], "no-check") self.assertTrue(up["content_type"].startswith("multipart/form-data; boundary=")) self.assertEqual(len(up["content_type"].split("boundary=")[1]), 32) # uuid4().hex self.assertEqual(up["headers"]["content-type"], "application/octet-stream") self.assertEqual(up["disposition"]["filename*"], name) self.assertEqual(self.jira.calls, 1) self.assertEqual(self.fake.requests[-1], ("POST", "/rest/api/2/issue/AURORA-1/attachments")) def test_upload_filename_with_quote_and_newline(self): self.fake.add("AURORA-1") name = 'итог "кв.2"\r\n.txt' self.jira.upload("AURORA-1", name, b"abc") up = self.fake.uploads[-1] self.assertEqual(up["disposition"]["filename"], "итог %22кв.2%22%0D%0A.txt") self.assertEqual(up["disposition"]["filename*"], name) self.assertEqual(len(self.fake.attachments["AURORA-1"]), 1) self.assertEqual(self.fake.attachments["AURORA-1"][0]["data"], b"abc") def test_upload_needs_xsrf_header(self): self.fake.add("AURORA-1") self.jira.headers = {k: v for k, v in self.jira.headers.items() if k != "X-Atlassian-Token"} with self.assertRaises(agent.HttpError) as cm: self.jira.upload("AURORA-1", "a.txt", b"abc") self.assertEqual(cm.exception.code, 403) self.assertNotIn("AURORA-1", self.fake.attachments) def test_upload_retries_429(self): self.fake.add("AURORA-1") self.fake.fail_next.append((429, {"Retry-After": "0"})) self.jira.upload("AURORA-1", "a.txt", b"abc") self.assertEqual(len(self.fake.attachments["AURORA-1"]), 1) self.assertEqual(self.jira.rate_limited, 1) self.assertEqual(self.jira.calls, 2) def test_upload_timeout_at_least_120(self): self.fake.add("AURORA-1") seen = [] orig = self.jira.send def spy(*a, **kw): seen.append(kw.get("timeout")) return orig(*a, **kw) self.jira.send = spy self.jira.upload("AURORA-1", "a.txt", b"abc") self.assertEqual(seen, [120]) # timeout в конфиге тестов — 5 с ``` Run: `/usr/bin/python3 -m unittest tests.test_clients -v` Expected: FAIL — `AttributeError: module 'agent' has no attribute 'multipart_file'` / `'wire_filename'`, `'JiraClient' object has no attribute 'upload'`. - [ ] **Step 4: Падающие тесты файла у хаба (HTTP и папка)** В `tests/test_folder.py` в импорты (после `import io`) добавить `import base64`; в класс `FolderTransportTest` после `test_http_url_keeps_http_transport` добавить: ```python def test_file_through_folder(self): self.run_relay() data = os.urandom(300 * 1024) sha = self.hub.put_file(data) got = self.client().file(sha) self.assertEqual((got["file"], got["size"]), (sha, len(data))) self.assertEqual(base64.b64decode(got["content"]), data) self.assertEqual(self.relay.requests[0]["path"], "/agent/files/" + sha) self.assertEqual(self.relay.requests[0]["method"], "GET") def test_file_missing_through_folder(self): self.run_relay() with self.assertRaises(agent.HttpError) as e: self.client().file("0" * 64) self.assertEqual(e.exception.code, 404) self.assertIn("no_file", e.exception.text) def test_file_over_http(self): sha = self.hub.put_file(b"abc") hub = agent.HubClient({"hub_url": self.hub.base_url, "hub_token": self.hub.token, "timeout": 5}) got = hub.file(sha) self.assertEqual(base64.b64decode(got["content"]), b"abc") self.assertEqual(self.hub.file_requests, 1) ``` Run: `/usr/bin/python3 -m unittest tests.test_folder -v` Expected: FAIL — `AttributeError: 'HubClient' object has no attribute 'file'`. - [ ] **Step 5: Реализация в `src/agent.py`** 1. Импорт `typing` (стр. 34) заменить на: ```python from typing import Any, Callable, Dict, List, Optional ``` 2. Перед `class HttpError` (раздел `# HTTP`) добавить: ```python def wire_filename(name: str) -> str: """Имя для filename="…" в multipart: кавычка и переводы строк — %22, %0D, %0A, как у браузеров (HTML5); остальное — UTF-8 как есть (Jira Server читает filename в кодировке запроса).""" return name.replace('"', "%22").replace("\r", "%0D").replace("\n", "%0A") def multipart_file(boundary: str, filename: str, data: bytes) -> bytes: """Тело multipart/form-data с одной частью file (спека agent «Тип задачи и вложения»).""" disposition = "form-data; name=\"file\"; filename=\"%s\"; filename*=UTF-8''%s" % ( wire_filename(filename), urllib.parse.quote(filename, safe="")) head = "--%s\r\nContent-Disposition: %s\r\nContent-Type: application/octet-stream\r\n\r\n" % (boundary, disposition) return head.encode("utf-8") + data + ("\r\n--%s--\r\n" % boundary).encode("ascii") ``` 3. В `class Http` заменить метод `call` целиком на два метода: ```python def call(self, method: str, path: str, body=None, params=None, timeout=None) -> Any: data = json.dumps(body, ensure_ascii=False).encode("utf-8") if body is not None else None return self.send(method, path, data, "application/json; charset=utf-8" if data is not None else None, params, timeout) def send(self, method: str, path: str, data: Optional[bytes], content_type: Optional[str], params=None, timeout=None) -> Any: """Запрос с готовым телом (JSON или multipart); ответ — JSON. Ошибки — HttpError и ConnectionError.""" url = self.base + path if params: url += "?" + urllib.parse.urlencode({k: v for k, v in params.items() if v is not None}) req = urllib.request.Request(url, data=data, method=method) for k, v in self.headers.items(): req.add_header(k, v) req.add_header("Accept", "application/json") if content_type is not None: req.add_header("Content-Type", content_type) try: with urllib.request.urlopen(req, timeout=timeout or self.timeout, context=self.ctx) as resp: raw = resp.read() status = resp.status except urllib.error.HTTPError as e: self._touch() try: headers = {k.lower(): v for k, v in e.headers.items()} text = e.read().decode("utf-8", "replace") except (OSError, ConnectionError, TimeoutError, http.client.HTTPException) as err: raise ConnectionError("%s: обрыв при чтении ответа: %s" % (self.base, err)) from None finally: e.close() raise HttpError(e.code, text, headers) from None except (urllib.error.URLError, ConnectionError, TimeoutError, OSError, http.client.HTTPException) as e: raise ConnectionError("%s недоступен: %s" % (self.base, e)) from None self._touch() try: return json.loads(raw.decode("utf-8")) if raw else {} except ValueError as e: # включая UnicodeDecodeError и JSONDecodeError raise HttpError(status, "ответ не JSON: %s; начало: %s" % (e, raw[:200].decode("utf-8", "replace"))) from None ``` 4. В `class JiraClient` заменить метод `call` целиком на: ```python def _retry(self, request: Callable[[], Any]) -> Any: """429/503 — повтор по Retry-After (до 5 запросов, не более 4 ожиданий); прочие ошибки — наружу.""" for attempt in range(1, 6): try: return request() except HttpError as e: if e.code in (429, 503) and attempt < 5: wait = 60 * attempt try: wait = max(0, int(e.headers.get("retry-after"))) except (TypeError, ValueError): pass self.rate_limited += 1 log(" ! HTTP %d, жду %d с (попытка %d/5)" % (e.code, wait, attempt)) self.sleep(wait) continue if e.code in (429, 503): self.rate_limited += 1 raise def call(self, method, path, body=None, params=None): return self._retry(lambda: Http.call(self, method, path, body, params)) def upload(self, key: str, filename: str, data: bytes) -> Any: """Вложение в задачу: multipart, X-Atlassian-Token: no-check (заголовок клиента), таймаут не меньше 120 с. Содержимое не логируется.""" boundary = uuid.uuid4().hex body = multipart_file(boundary, filename, data) return self._retry(lambda: self.send("POST", API + "/issue/%s/attachments" % key, body, "multipart/form-data; boundary=" + boundary, timeout=max(self.timeout, 120))) ``` 5. В `class HubClient` после метода `known_keys` добавить: ```python def file(self, sha256: str) -> dict: """Файл вложения из хранилища хаба: {file, size, content (base64)}; нет файла — HttpError 404 no_file.""" return self.call("GET", "/agent/files/%s" % sha256) ``` - [ ] **Step 6: Тесты проходят** Run: `/usr/bin/python3 -m unittest tests.test_clients tests.test_folder -v` Expected: PASS. - [ ] **Step 7: ARCHITECTURE.md** В `ARCHITECTURE.md` строку таблицы `| HTTP | …` заменить на: ```markdown | 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` | ``` - [ ] **Step 8: Весь набор** Run: `/usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t .` Expected: `OK`. - [ ] **Step 9: Commit** ```bash git add src/agent.py tests/fake_jira.py tests/fake_hub.py tests/test_clients.py tests/test_folder.py ARCHITECTURE.md git commit -m "feat(agent): multipart-загрузка вложения в Jira и файл вложения у хаба (#31)" -m "Co-Authored-By: Claude Opus 5.5 " ``` --- ### Task 4: Команда `ensure_attachment` в исполнителе и цикле **Модель исполнителя:** sonnet (интеграция: исполнитель, повтор в `Runner.execute`, журнал и лог). **Files:** - Modify: `src/agent.py` — константа `FILE_GONE` (после `class HubError`), `Executor.hub_file` и `Executor.do_ensure_attachment` (новый раздел `# --- ensure_attachment` после `do_ensure_sprint`, перед `# --- refresh`) - Modify: `tests/_common.py` (`ZERO_PACING` — `ensure_attachment`) - Create: `tests/test_executor_attachments.py` - Modify: `tests/test_runner.py` (повтор после обрыва, повреждённый файл, содержимое не в журнале и логе) - Modify: `README.md` (строка `ensure_attachment` таблицы команд), `ARCHITECTURE.md` (строка «Исполнитель»), `CLAUDE.md` (границы `Executor`, список тестовых файлов) **Interfaces:** - Consumes: Task 3 — `JiraClient.upload(key, filename, data) -> Any`, `HubClient.file(sha256) -> dict`, `wire_filename(name) -> str`; фейки `FakeJira.attachments`, `add_attachment`, `drop_next_post_attachment`, `attachment_limit`, `bad_attachment_response`, `FakeHub.files`, `put_file`, `file_requests`. - Produces: `FILE_GONE = "файл удалён с хаба, загрузите заново"`; `Executor.hub_file(sha: str, size: int) -> Optional[bytes]` (`None` — 404; повреждение и сбой — `HubError`); `Executor.do_ensure_attachment(p: dict, cmd: dict) -> dict` — результат `{"attachment_id": str, "existed": True}` или `{"attachment_id": str, "created": True}`. - [ ] **Step 1: Темп в тестах** В `tests/_common.py` заменить `ZERO_PACING` на: ```python ZERO_PACING = {"ensure_issue": 0, "ensure_fields": 0, "ensure_comment": 0, "ensure_status": 0, "ensure_link": 0, "ensure_sprint": 0, "ensure_attachment": 0, "refresh": 0} ``` (Агенту правка не нужна: `ensure_attachment` пишет в Jira один раз и `gap` не вызывает; паузу перед командой задаёт `pause_seconds` хаба, а `Executor.gap` для неизвестного типа берёт 0.) - [ ] **Step 2: Падающие тесты исполнителя** Создать `tests/test_executor_attachments.py`: ```python 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() ``` Run: `/usr/bin/python3 -m unittest tests.test_executor_attachments -v` Expected: FAIL — `failed` «неизвестный тип команды ensure_attachment» во всех тестах. - [ ] **Step 3: Падающие тесты цикла** В `tests/test_runner.py` импорты в начале файла заменить на: ```python import base64 import hashlib import json import os import tempfile import threading import time import unittest from datetime import datetime, timedelta, timezone ``` В класс `RunnerTest` после `test_retry_exhausted_on_hub_names_hub` добавить: ```python 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) # размер — в логе ``` Run: `/usr/bin/python3 -m unittest tests.test_runner -v` Expected: FAIL — четыре новых теста: отчёт `failed` «неизвестный тип команды ensure_attachment». - [ ] **Step 4: Реализация** В `src/agent.py` после `class HubError` (после его `__init__`) добавить: ```python FILE_GONE = "файл удалён с хаба, загрузите заново" ``` После метода `do_ensure_sprint` (перед строкой `# --- refresh ---…`) добавить: ```python # --- 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}) ``` - [ ] **Step 5: Тесты исполнителя и цикла проходят** Run: `/usr/bin/python3 -m unittest tests.test_executor_attachments tests.test_runner -v` Expected: PASS. - [ ] **Step 6: Документация** 1. `README.md`, таблица «Что делает агент с командами»: после строки `ensure_sprint` добавить: ```markdown | `ensure_attachment` | ищет у задачи вложение с тем же именем и размером — есть: ничего не делает; нет: берёт файл у хаба (`/agent/files/{sha256}`), сверяет sha256 и размер и загружает в Jira (`multipart/form-data`); файл удалён с хаба — `failed` «загрузите заново»; повреждённый файл — повтор, как обрыв связи с хабом. Содержимое файла в журнал и лог не пишется | ``` 2. `ARCHITECTURE.md`, строку таблицы `| Исполнитель | …` заменить на: ```markdown | Исполнитель | `Executor` и `do_<тип>` | по одному методу на тип команды: читает Jira, пишет только разницу; `refresh` читает и отправляет состояние в хаб; `ensure_attachment` берёт файл вложения у хаба (`hub_file`: sha256 и размер, повреждение — `HubError` с повтором) | `JiraClient`, `HubClient` (`refresh` и файл в `ensure_attachment`), настройки хаба | ``` 3. `CLAUDE.md`, раздел «Архитектура»: строку ```markdown - Границы: `Executor` знает Jira, но не знает хаб (кроме `refresh`, где хаб нужен для `known_keys` и `state`). ``` заменить на ```markdown - Границы: `Executor` знает Jira, но не знает хаб (кроме `refresh`, где хаб нужен для `known_keys` и `state`, и `ensure_attachment`, где у хаба берётся файл вложения). ``` 4. `CLAUDE.md`, раздел «Тесты»: в перечне файлов после `` `test_executor_links.py` (`ensure_status`, `ensure_link`, `ensure_sprint`), `` добавить `` `test_executor_attachments.py` (`ensure_attachment`), ``. - [ ] **Step 7: Весь набор** Run: `/usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t .` Expected: `OK`. - [ ] **Step 8: Commit** ```bash git add src/agent.py tests/_common.py tests/test_executor_attachments.py tests/test_runner.py README.md ARCHITECTURE.md CLAUDE.md git commit -m "feat(agent): команда ensure_attachment — вложение по имени и размеру, файл у хаба с проверкой sha256 (#31)" -m "Co-Authored-By: Claude Opus 5.5 " ``` --- ### Task 5: Версия 11, документация, change решений агента **Модель исполнителя:** haiku (готовый текст). **Files:** - Modify: `src/agent.py:36` (`AGENT_VERSION`) - Modify: `tests/test_folder.py:160-161` (`test_version_is_9`) - Modify: `README.md` (пункт «Версия» раздела «Что делает агент с командами») - Modify: `CLAUDE.md` (раздел «Первая подсистема» — последний change) - Create: `openspec/changes/agent-v11-bug-attachments/design.md` (через `openspec new change agent-v11-bug-attachments`) **Interfaces:** - Consumes: Task 1–4. - Produces: `AGENT_VERSION = 11`. - [ ] **Step 1: Падающий тест версии** В `tests/test_folder.py` метод ```python def test_version_is_9(self): self.assertEqual(agent.AGENT_VERSION, 10) ``` заменить на ```python def test_version(self): self.assertEqual(agent.AGENT_VERSION, 11) ``` Run: `/usr/bin/python3 -m unittest tests.test_folder -v` Expected: FAIL — `AssertionError: 10 != 11`. - [ ] **Step 2: Версия** В `src/agent.py` строку `AGENT_VERSION = 10` заменить на `AGENT_VERSION = 11`. Run: `/usr/bin/python3 -m unittest tests.test_folder -v` Expected: PASS. - [ ] **Step 3: README — версия** В `README.md`, пункт `- **Версия.**` раздела «Что делает агент с командами»: `(сейчас 9)` заменить на `(сейчас 11)`, а после предложения `` …источник ошибки хаба в `refresh` (номера 6–8 заняты живой проверкой самообновления). `` дописать строку того же отступа (2 пробела): ```markdown Версия 10 — комментарии задач в состоянии `refresh`. Версия 11 — тип задачи из `issue_types` с ошибкой конфигурации (тип `bug` — «Ошибка»), команда `ensure_attachment`, устойчивое дочитывание комментариев (M-1, M-2 из #30). Агент v10 на `ensure_attachment` отвечает `failed` «неизвестный тип команды»; хаб после выкатки ставит `min_agent_version = 11`. ``` - [ ] **Step 4: CLAUDE.md** В `CLAUDE.md`, раздел «Первая подсистема», заменить строку, начинающуюся с `Последний change —`, на: ```markdown Последний change — `../jira-hub/openspec/changes/bug-and-attachments/` (агент v11: тип «Ошибка», `ensure_attachment`, устойчивое дочитывание комментариев; решения агента — `openspec/changes/agent-v11-bug-attachments/design.md`), план — `docs/superpowers/plans/2026-10-15-bug-and-attachments-agent.md`. ``` - [ ] **Step 5: Change решений агента** Run: `openspec new change agent-v11-bug-attachments` Expected: создан каталог `openspec/changes/agent-v11-bug-attachments/`. Создать `openspec/changes/agent-v11-bug-attachments/design.md` (таблица — те же строки R-20…R-26, что в разделе «Decisions made without discussion» этого плана; решения, принятые при исполнении, контроллер дописывает следующими номерами, начиная с R-27): ```markdown # agent-v11-bug-attachments — решения агента Спека и дизайн изменения — в `jira-hub/openspec/changes/bug-and-attachments/` (issue aep/jira-hub#43, aep/jira-agent#31). Агентская часть — раздел «Агент v11» дизайна хаба и требования `agent` «Тип задачи и вложения» (ADDED) и «Чтение состояния» (MODIFIED: M-1, M-2 из aep/jira-agent#30). Решения владельца 15.10.2026 (brainstorming jira-hub#43): файл приходит только из MCP; обязательные поля «Ошибки» — как у «Задачи»; файл хранится в хабе, агент забирает его отдельным запросом `GET /agent/files/{sha256}` (base64 в JSON). ## Decisions made without discussion Решения 1–8 записаны в `jira-hub/openspec/changes/bug-and-attachments/design.md` (раздел «Decisions made without discussion»); здесь — только решения агентской части. | Маркер | Дата | Решение | Обоснование | Цена ошибки | |---|---|---|---|---| | R-20 | 2026-10-15 | Любой 404 от `GET /agent/files/{sha}` — `failed` «файл удалён с хаба, загрузите заново», без разбора кода `no_file` в теле | Спека hub-api: у маршрута 404 бывает только `no_file`; агент v11 публикуется после выкатки хаба с маршрутом (design «Выкатка», шаги 1 и 3) | Если хаб без маршрута (откат хаба) — вводящий в заблуждение текст «загрузите заново» вместо «нет маршрута»; повторная загрузка не поможет, причина видна по логу хаба | | R-21 | 2026-10-15 | Таймаут `GET /agent/files` — обычный `timeout` агента (60 с), не поднимается до 120 с, как у загрузки в Jira | design Risks: круг папки для файла 10 МБ (≈13,4 МБ JSON) — секунды, «таймаут вызова агента 60 с с запасом»; таймаут — `ConnectionError` → повтор как обрыв связи с хабом | На медленной папке большой файл не успеет за 60 с: 4 попытки и `failed` «результат неизвестен»; лечится `timeout` в `agent.json` | | R-22 | 2026-10-15 | Ответ хаба на файл с кодом 2xx, но не JSON — временный сбой («ответ на файл не JSON») без текста `HttpError` | Текст `Http` содержит начало тела, то есть base64 содержимого, а спека запрещает содержимое в журнале и логе; обрезанный ответ — сбой пути, как повреждение | Диагностика беднее: начала тела в ошибке нет, причину искать по логу хаба/реле | | R-23 | 2026-10-15 | В `filename="…"` кавычка и переводы строк кодируются как у браузеров (`%22`, `%0D`, `%0A`); при поиске уже приложенного вложения сравниваются обе формы имени — исходная и «браузерная» | Хаб запрещает в имени только `/` и `\`; сырая кавычка ломает заголовок части; если Jira сохранит имя в закодированной форме, без второй формы каждый повтор команды создавал бы дубль | Вложение с такими символами в Jira может называться `…%22…`; другое вложение, чьё имя совпало с «браузерной» формой, будет принято за уже приложенное | | R-24 | 2026-10-15 | Ответ Jira на загрузку не список или без `id` у первого элемента — `failed` «Jira не вернула id вложения в ответе на загрузку», без повтора | Спека: результат — `attachment_id` из ответа; повтор команды человеком найдёт вложение по имени и размеру и вернёт `done` с id | Если Jira на самом деле не сохранила вложение, `failed` без повтора требует ручного повтора команды | | R-25 | 2026-10-15 | Загрузка вложения идёт через тот же повтор 429/503 по `Retry-After`, что и остальные вызовы Jira (`JiraClient._retry`) | 429/503 — запрос не обработан, повтор безопасен; спека «Ограничение темпа и 429» не делает исключений для записи | 503 от прокси после фактической записи даст второе вложение; вероятность низкая, лишнее удаляется руками | | R-26 | 2026-10-15 | Тип задачи проверяется до поиска дубля в `ensure_issue`: без настройки типа — ни одного вызова Jira, даже если задача с той же темой уже есть | Спека: «`failed` без вызова Jira»; настройку могли убрать между постановкой и выдачей (design решение 6) | Повтор `ensure_issue` по уже созданной задаче после удаления настройки типа даёт `failed` вместо `done` с найденным ключом | ``` - [ ] **Step 6: Весь набор** Run: `/usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t .` Expected: `OK` (до плана — 230 тестов; после — 230 + новые из Task 1–4). - [ ] **Step 7: Проверка синтаксиса 3.9 и самопроверка CLI** Run: `/usr/bin/python3 -m unittest tests.test_py39 -v && /usr/bin/python3 -c "import ast,sys; ast.parse(open('src/agent.py',encoding='utf-8').read()); print('ok')"` Expected: PASS и `ok`. - [ ] **Step 8: Commit** ```bash git add src/agent.py tests/test_folder.py README.md CLAUDE.md openspec/changes/agent-v11-bug-attachments git commit -m "chore(agent): версия 11, README и решения агента для bug-and-attachments (#31)" -m "Co-Authored-By: Claude Opus 5.5 " ``` --- ## Decisions made without discussion Нумерация рулингов агентской части — с R-20, чтобы не пересечься с рулингами исполнения хабовой части change. | Маркер | Дата | Решение | Обоснование | Цена ошибки | |---|---|---|---|---| | R-20 | 2026-10-15 | Любой 404 от `GET /agent/files/{sha}` — `failed` «файл удалён с хаба, загрузите заново», без разбора кода `no_file` в теле | Спека hub-api: у маршрута 404 бывает только `no_file`; агент v11 публикуется после выкатки хаба с маршрутом (design «Выкатка», шаги 1 и 3) | Если хаб без маршрута (откат хаба) — вводящий в заблуждение текст «загрузите заново» вместо «нет маршрута»; повторная загрузка не поможет, причина видна по логу хаба | | R-21 | 2026-10-15 | Таймаут `GET /agent/files` — обычный `timeout` агента (60 с), не поднимается до 120 с, как у загрузки в Jira | design Risks: круг папки для файла 10 МБ (≈13,4 МБ JSON) — секунды, «таймаут вызова агента 60 с с запасом»; таймаут — `ConnectionError` → повтор как обрыв связи с хабом | На медленной папке большой файл не успеет за 60 с: 4 попытки и `failed` «результат неизвестен»; лечится `timeout` в `agent.json` | | R-22 | 2026-10-15 | Ответ хаба на файл с кодом 2xx, но не JSON — временный сбой («ответ на файл не JSON») без текста `HttpError` | Текст `Http` содержит начало тела, то есть base64 содержимого, а спека запрещает содержимое в журнале и логе; обрезанный ответ — сбой пути, как повреждение | Диагностика беднее: начала тела в ошибке нет, причину искать по логу хаба/реле | | R-23 | 2026-10-15 | В `filename="…"` кавычка и переводы строк кодируются как у браузеров (`%22`, `%0D`, `%0A`); при поиске уже приложенного вложения сравниваются обе формы имени — исходная и «браузерная» | Хаб запрещает в имени только `/` и `\`; сырая кавычка ломает заголовок части; если Jira сохранит имя в закодированной форме, без второй формы каждый повтор команды создавал бы дубль | Вложение с такими символами в Jira может называться `…%22…`; другое вложение, чьё имя совпало с «браузерной» формой, будет принято за уже приложенное | | R-24 | 2026-10-15 | Ответ Jira на загрузку не список или без `id` у первого элемента — `failed` «Jira не вернула id вложения в ответе на загрузку», без повтора | Спека: результат — `attachment_id` из ответа; повтор команды человеком найдёт вложение по имени и размеру и вернёт `done` с id | Если Jira на самом деле не сохранила вложение, `failed` без повтора требует ручного повтора команды | | R-25 | 2026-10-15 | Загрузка вложения идёт через тот же повтор 429/503 по `Retry-After`, что и остальные вызовы Jira (`JiraClient._retry`) | 429/503 — запрос не обработан, повтор безопасен; спека «Ограничение темпа и 429» не делает исключений для записи | 503 от прокси после фактической записи даст второе вложение; вероятность низкая, лишнее удаляется руками | | R-26 | 2026-10-15 | Тип задачи проверяется до поиска дубля в `ensure_issue`: без настройки типа — ни одного вызова Jira, даже если задача с той же темой уже есть | Спека: «`failed` без вызова Jira»; настройку могли убрать между постановкой и выдачей (design решение 6) | Повтор `ensure_issue` по уже созданной задаче после удаления настройки типа даёт `failed` вместо `done` с найденным ключом | ## После плана - Финальное ревью ветки (opus), затем PR в aep/jira-agent со ссылкой на #31 и на change `bug-and-attachments` (merge — после PR хаба, design «Выкатка»). - После merge: `uv run --project ../jira-hub jira-hub agent publish src/agent.py` из основного checkout `jira-agent` на main — пишет в каталог живого хаба, выполняется только по отдельной команде владельца; проверка — MCP `agent_status` «обновление: ok v10 → v11»; затем `min_agent_version = 11` и живая проверка (design «Выкатка», шаги 3–4: «Ошибка» в бэклог + вложение с кириллическим именем, повтор пакета без дублей).