jira-agent/docs/superpowers/plans/2026-10-15-bug-and-attachments-agent.md
Andrey Epifancev da5d0b1d6d docs(plan): план агента v11 bug-and-attachments (#31)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 00:09:40 +04:00

1396 lines
83 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.<issue_type>`: `{id}`, если `id` задан, иначе `{name}`; нет записи или в ней
нет ни `id`, ни `name` — ошибка конфигурации «нет настройки issue_types.<issue_type>», `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 <noreply@anthropic.com>`.
## 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 <noreply@anthropic.com>"
```
---
### 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.<kind>»; `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.<kind>: {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 <noreply@anthropic.com>"
```
---
### 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 <noreply@anthropic.com>"
```
---
### 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 <noreply@anthropic.com>"
```
---
### 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 — временный сбой («ответ на файл <sha> не 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 <noreply@anthropic.com>"
```
---
## 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 — временный сбой («ответ на файл <sha> не 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: «Ошибка»
в бэклог + вложение с кириллическим именем, повтор пакета без дублей).