83 KiB
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_VERSIONSHALL быть 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 и длина декодированногоcontentSHALL совпасть с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
- Jira приняла файл, но соединение оборвалось до ответа — повтор через проверку находит вложение по имени и размеру,
второго вложения нет, файл у хаба второй раз не берётся (Task 4,
test_attachment_upload_dropped_no_duplicate). - Имя файла с кавычкой или переводом строки — multipart не ломается (одна часть, тело цело), а повтор узнаёт уже
загруженное вложение и под «браузерной» формой имени (Task 3,
test_upload_filename_with_quote_and_newline; Task 4,test_existing_under_wire_name). - Хаб ответил 2xx, но тело не JSON (обрезанный ответ) — временный сбой; начало тела (то есть содержимого файла в base64)
не попадает в текст ошибки, журнал и лог (Task 4,
test_hub_non_json_file_no_content_in_error). - Jira ответила 429 на загрузку — повтор по
Retry-Afterтем же правилом, что у остальных вызовов; одно вложение (Task 3,test_upload_retries_429). - Ответ 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:
- Импорт: строку
from urllib.parse import parse_qs, urlparseзаменить на
from urllib.parse import parse_qs, unquote, urlparse
- После
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
- В
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 = {} # заголовки текущего запроса (имена в нижнем регистре)
- После метода
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"]}
- В
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, [])]
- В
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
- В
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:
- Импорты: перед
import jsonдобавить
import base64
import hashlib
- В
FakeHub.__init__послеself.claim_wait = 0 …добавить:
self.files = {} # sha256 → содержимое (хранилище файлов вложений хаба)
self.file_requests = 0
- После метода
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
- В
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"]},
- В
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"})
- В
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
- Импорт
typing(стр. 34) заменить на:
from typing import Any, Callable, Dict, List, Optional
- Перед
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")
- В
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
- В
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)))
- В
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: Документация
README.md, таблица «Что делает агент с командами»: после строкиensure_sprintдобавить:
| `ensure_attachment` | ищет у задачи вложение с тем же именем и размером — есть: ничего не делает; нет: берёт файл у хаба (`/agent/files/{sha256}`), сверяет sha256 и размер и загружает в Jira (`multipart/form-data`); файл удалён с хаба — `failed` «загрузите заново»; повреждённый файл — повтор, как обрыв связи с хабом. Содержимое файла в журнал и лог не пишется |
ARCHITECTURE.md, строку таблицы| Исполнитель | …заменить на:
| Исполнитель | `Executor` и `do_<тип>` | по одному методу на тип команды: читает Jira, пишет только разницу; `refresh` читает и отправляет состояние в хаб; `ensure_attachment` берёт файл вложения у хаба (`hub_file`: sha256 и размер, повреждение — `HubError` с повтором) | `JiraClient`, `HubClient` (`refresh` и файл в `ensure_attachment`), настройки хаба |
CLAUDE.md, раздел «Архитектура»: строку
- Границы: `Executor` знает Jira, но не знает хаб (кроме `refresh`, где хаб нужен для `known_keys` и `state`).
заменить на
- Границы: `Executor` знает Jira, но не знает хаб (кроме `refresh`, где хаб нужен для `known_keys` и `state`, и `ensure_attachment`, где у хаба берётся файл вложения).
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из основного checkoutjira-agentна main — пишет в каталог живого хаба, выполняется только по отдельной команде владельца; проверка — MCPagent_status«обновление: ok v10 → v11»; затемmin_agent_version = 11и живая проверка (design «Выкатка», шаги 3–4: «Ошибка» в бэклог + вложение с кириллическим именем, повтор пакета без дублей).