diff --git a/docs/superpowers/plans/2026-10-15-bug-and-attachments-agent.md b/docs/superpowers/plans/2026-10-15-bug-and-attachments-agent.md new file mode 100644 index 0000000..36c2588 --- /dev/null +++ b/docs/superpowers/plans/2026-10-15-bug-and-attachments-agent.md @@ -0,0 +1,1395 @@ +# 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: «Ошибка» + в бэклог + вложение с кириллическим именем, повтор пакета без дублей).