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

83 KiB
Raw Permalink Blame History

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 … добавить:

        self.comment_denied = {}         # ключ → код ответа на GET /issue/{key}/comment (403 — нет прав, 404 — нет задачи)

В route, в обработчике ^/rest/api/2/issue/([^/]+)/comment$ заменить начало

        if m:
            key = m.group(1)
            self.issues[key]

на

        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 добавить:

    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 целиком:

    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 заменить строку

            st["comments"] = self.comments_of(r)

на

            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 пробела):

   С v11 комментарий без `id` пропускается (строка в логе с ключом задачи), а если Jira не дала дочитать комментарии
   задачи (403 или 404), задача уходит без `comments` — хаб оставляет прежние; `refresh` продолжается.
  • Step 6: Commit
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.»; SETTINGS["issue_types"]["bug"] = {"name": "Ошибка", "id": None}.

  • Step 1: Настройки тестов

В tests/_common.py заменить строку "issue_types": … на:

    "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 добавить:

    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 целиком:

    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 первые строки метода

    def do_ensure_issue(self, p: dict, cmd: dict) -> dict:
        summary = (p["summary"] or "").strip()

заменить на

    def do_ensure_issue(self, p: dict, cmd: dict) -> dict:
        issuetype = self.issue_type_ref(p["issue_type"])   # до поиска дубля: без настройки — ни одного вызова Jira
        summary = (p["summary"] or "").strip()

и в той же функции строку

        fields = {"project": {"key": self.s["project_key"]}, "issuetype": self.issue_type_ref(p["issue_type"]),
                  "summary": p["summary"]}

на

        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 заменить на:

| `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
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 заменить на
from urllib.parse import parse_qs, unquote, urlparse
  1. После CUSTOM = (…) добавить функции модуля:
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
  1. В FakeJira.__init__ после строки self.comment_denied = {} … (Task 1) добавить:
        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 = {}           # заголовки текущего запроса (имена в нижнем регистре)
  1. После метода add_comment добавить:
    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"]}
  1. В issue_json перед строкой return {"key": key, "id": f["id"], "fields": out} добавить:
        if fields is None or "attachment" in fields:
            out["attachment"] = [self.attachment_json(a) for a in self.attachments.get(key, [])]
  1. В handle заменить строки
        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))

на

        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)) добавить:

        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
  1. В route после обработчика ^/rest/api/2/issue/([^/]+)/comment/(\d+)$ добавить:
        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 добавить
import base64
import hashlib
  1. В FakeHub.__init__ после self.claim_wait = 0 … добавить:
        self.files = {}                 # sha256 → содержимое (хранилище файлов вложений хаба)
        self.file_requests = 0
  1. После метода enqueue добавить:
    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
  1. В route, в ветке /agent/claim, строку
            return 200, {"command": {"id": cid, "type": c["type"], "params": c["params"], "pause_seconds": c["pause_seconds"]},

заменить на

            return 200, {"command": {"id": cid, "type": c["type"], "params": self._issued(c), "pause_seconds": c["pause_seconds"]},
  1. В route ветку ^/agent/commands/(\d+)$
        if m:
            c = self.commands.get(int(m.group(1)))
            return (200, c) if c else (404, {"error": "not_found"})

заменить на

        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"})
  1. В route перед m = re.match(r"^/agent/commands/(\d+)$", path) добавить:
        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 импорты заменить на:

import unittest
import urllib.parse

from _common import agent, jira_cfg, no_sleep
from fake_jira import FakeJira

и в класс ClientsTest (после test_get_and_counters) добавить:

    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 добавить:

    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) заменить на:
from typing import Any, Callable, Dict, List, Optional
  1. Перед class HttpError (раздел # HTTP) добавить:
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")
  1. В class Http заменить метод call целиком на два метода:
    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
  1. В class JiraClient заменить метод call целиком на:
    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)))
  1. В class HubClient после метода known_keys добавить:
    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 | … заменить на:

| 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
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 на:

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:

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 импорты в начале файла заменить на:

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 добавить:

    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__) добавить:

FILE_GONE = "файл удалён с хаба, загрузите заново"

После метода do_ensure_sprint (перед строкой # --- refresh ---…) добавить:

    # --- 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 добавить:
| `ensure_attachment` | ищет у задачи вложение с тем же именем и размером — есть: ничего не делает; нет: берёт файл у хаба (`/agent/files/{sha256}`), сверяет sha256 и размер и загружает в Jira (`multipart/form-data`); файл удалён с хаба — `failed` «загрузите заново»; повреждённый файл — повтор, как обрыв связи с хабом. Содержимое файла в журнал и лог не пишется |
  1. ARCHITECTURE.md, строку таблицы | Исполнитель | … заменить на:
| Исполнитель | `Executor` и `do_<тип>` | по одному методу на тип команды: читает Jira, пишет только разницу; `refresh` читает и отправляет состояние в хаб; `ensure_attachment` берёт файл вложения у хаба (`hub_file`: sha256 и размер, повреждение — `HubError` с повтором) | `JiraClient`, `HubClient` (`refresh` и файл в `ensure_attachment`), настройки хаба |
  1. CLAUDE.md, раздел «Архитектура»: строку
- Границы: `Executor` знает Jira, но не знает хаб (кроме `refresh`, где хаб нужен для `known_keys` и `state`).

заменить на

- Границы: `Executor` знает Jira, но не знает хаб (кроме `refresh`, где хаб нужен для `known_keys` и `state`, и `ensure_attachment`, где у хаба берётся файл вложения).
  1. 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
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 метод

    def test_version_is_9(self):
        self.assertEqual(agent.AGENT_VERSION, 10)

заменить на

    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 пробела):

  Версия 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 —, на:

Последний 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):

# 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
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 — временный сбой («ответ на файл не 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: «Ошибка» в бэклог + вложение с кириллическим именем, повтор пакета без дублей).