Andrey Epifancev a59669d315 chore(openspec): архив eva-agent-mvp (#1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 10:06:37 +04:00

38 KiB
Raw Blame History

eva-agent-mvp — решения агента

Спека и дизайн — в jira-hub/openspec/changes/wiki-module/ (issue aep/jira-hub#91, PR aep/jira-hub#92):

  • решения 10, 11, 14 и Context (форматы ссылок Eva);
  • требования wiki «Синхронизация вики» и «Вложения вики по запросу»;
  • modules «Агенты модулей»;
  • hub-api «Маршруты агента» (общие /agent/*).

Issue агента — aep/eva-agent#1. Версия агента — 1. Агент работает за модуль wiki: имя агента в хабе wiki, токен — jira-hub tokens → «агент wiki».

Владелец 24.10.2026 разрешил делать агента ночью без обсуждения («завтра хочу уже иметь рабочий mvp»). Поэтому все решения ниже приняты агентом и перечислены в разделе «Decisions made without discussion».

Context

  • Источник для копирования — jira-agent v14 (../jira-agent/src/agent.py, run.py). Его части, не связанные с Jira, отлажены с v2 по v14:
    • HTTP-слой: Http, HttpError, RedirectAway, TooBig, _SameOriginRedirect;
    • транспорт FolderTransport и клиент хаба HubClient;
    • журнал Journal, цикл Runner, самообновление Updater;
    • acquire_lock, CLI run|check|selftest|prune, launcher_version.
  • Клиент Eva и обход дерева — по образцу ../eva_wiki_export.py:
    • JSON-RPC 2.2: POST <base>/api/?m=<метод>, Authorization: Bearer, тело {jsonrpc: "2.2", method, callid, kwargs};
    • повторы на 429/502/503/504 и сетевых сбоях;
    • CmfDocument.get/list, страницы списка по 200;
    • дерево — по parent/tree_parent из списка документов проекта (project.code), запасной путь — обход через tree_parent.code.
  • Хаб принимает:
    • пачки POST /agent/wiki/pages {command_id, pages} — до 50 страниц, тело до 16 МБ (MAX_BODY хаба);
    • форму страницы проверяет modules/wiki/stage.py;
    • отчёт wiki_sync done {root_title, codes, eva_calls}: codes[0] должен быть кодом корня;
    • отчёт wiki_fetch done {file, size}: файл должен лежать в хранилище хаба с этим размером.

Decisions made without discussion

Дата всех решений — 2026-10-24.

  1. Скопировать общие части jira-agent v14, а не выделять библиотеку. Обоснование: решение владельца 23.10 — однофайловый агент на stdlib на виртуалке. Копия сохраняет отлаженное поведение. Цена ошибки: правки общих частей придётся повторять в двух агентах.

  2. run.py — дословная копия запускателя jira-agent (LAUNCHER_VERSION = 2), вместе с переменной окружения JIRA_AGENT_LAUNCHER. Обоснование: в протоколе самообновления важны имена agent.py, update.json и AGENT_VERSION; переменная — внутреннее дело пары запускатель–агент. Цена ошибки: переименование переменной в обоих файлах.

  3. Конфиг agent.json:

    • обязательны hub_url, hub_token, eva.base_url, eva.token;
    • необязательны verify_ssl, ca_bundle, timeout (60), poll_seconds (5), auto_update (true).

    Обоснование: формат jira-agent, где ключ jira заменён на eva. Цена ошибки: правка шаблона и load_config.

  4. Пауза между вызовами Eva — wiki_pacing из GET /agent/settings (по умолчанию 0.2 с).

    • 429/502/503/504 и сетевые сбои: до 4 попыток с ожиданием 1, 2, 4 с.
    • При 429 учитывается Retry-After (не больше 60 с).
    • Число 429 уходит в rate_limited отчёта, число вызовов Eva — в jira_calls: поле протокола хаба, имя общее для всех агентов.

    Цена ошибки: Eva режет запросы; правится настройкой без выпуска агента.

  5. wiki_sync — порядок работы:

    1. Корень берётся через CmfDocument.get по коду из params.root. Нет корня — failed «страница <код> не найдена или нет прав».
    2. Дерево строится как в collect_subtree.
    3. Обход в глубину от корня с защитой от циклов (seen по id). Соседи — по (orderno is None, orderno, name).
    4. По каждой странице отдельный CmfDocument.get(fields=["code","name","text"]).
    5. Страницы уходят пачками по мере обхода: 50 страниц или 4 МБ JSON, что наступит раньше.
    6. Отчёт codes — коды в порядке обхода, корень первым.
    • Поля страницы: eva_id = str(id), parent_code — код родителя по обходу (у корня null), orderno — целое или null, url = urljoin(base_url + "/", "project/Document/<code>").

    Цена ошибки: дерево не совпадёт с Eva; лечится правкой обхода и новой версией через самообновление.

  6. Ссылки разбирает html.parser.HTMLParser из stdlib.

    • Берутся img[src] и a[href].
    • Адрес разрешается urljoin(base_url + "/", ref). «Свой» — тот же scheme и netloc, что у eva.base_url.
    • Вложение — свой адрес с путём, начинающимся на /files/. Из url убираются query и fragment. Миниатюра <путь>.meta/thumbnail.jpg означает файл <путь>.
    • Ссылка на страницу — свой адрес с DOC-<цифры> в пути (код в верхнем регистре).
    • static/… и прочие ссылки не передаются: значки убирает хаб, прочие остаются в тексте.
    • Имя вложения — alt или title, затем текст ссылки, затем последний сегмент пути после unquote.
    • Каждая ссылка передаётся отдельной записью {ref, url, name} или {ref, code}, ref — ровно значение атрибута. Повторы по url склеивает хаб.

    Обоснование: форматы из выгрузки ТЗ АРМ КА (design хаба, Context). Цена ошибки: вложение продублируется или потеряется; проверяется на первой живой синхронизации, правится агентом.

  7. Без ?version=1 в url вложения. Обоснование: миниатюра и файл дают один адрес, хаб склеивает вложение. Цена ошибки: если Eva без version не отдаёт файл, wiki_fetch падает; правка одной строки.

  8. wiki_fetch:

    • адрес не своего хоста — failed «чужой хост», запроса нет;
    • файл скачивается с Bearer-токеном Eva потоком до 10 МБ + 1 байт;
    • Content-Length больше 10 МБ или прочитано больше — failed «файл больше 10 МБ — откройте в Eva»;
    • иначе POST /agent/files и done {file, size};
    • HTTP 404 — failed «файл не найден в Eva».

    Цена ошибки: большие файлы только ссылкой.

  9. Обе команды только читают Eva, поэтому восстановление после перезапуска выполняет их заново. Журнал нужен только для досылки отчёта; wiki_sync не продолжается с середины. Хаб очищает стадию при повторной выдаче. Цена ошибки: лишняя полная перекачка после перезапуска.

  10. check:

    • хаб: GET /agent/settings своим токеном;
    • Eva: CmfDocument.list(filter=[], fields=["code"], slice=[0, 1]);
    • вывод «хаб: ok», «eva: ok» или текст ошибки, код 0 или 3, как у jira-agent;
    • selftest — версия плюс check.
  11. Тесты:

    • unittest на /usr/bin/python3 (3.9);
    • фейки tests/fake_hub.py (как у jira-agent, плюс POST /agent/wiki/pages), tests/fake_eva.py (JSON-RPC на http.server), tests/fake_relay.py;
    • tests/test_py39.py — копия.
    • Тесты общих частей переносятся из jira-agent и адаптируются: журнал, транспорт, самообновление, запускатель.
  12. ref вложений и ссылок — значение атрибута после unescape (так отдаёт html.parser). Обоснование: хаб (convert.py, BeautifulSoup .get("src"/"href")) ищет запись по тому же unescaped-значению. Цена ошибки: адреса с &amp; не сопоставятся, вложение останется неразрешённым; правка одной строки.

  13. При расхождении плана с кодом хаба по протоколу прав код хаба. Поручение владельца; расхождения фиксируются находками O-n, хаб не правится. Цена ошибки: агент подгонится под ошибочное поведение хаба; видно в e2e.

  14. Каркас EvaClient создан в задаче 1; Runner(cfg, hub, eva, journal). В EvaClient — только Bearer-заголовок, TLS-настройки, счётчики calls/rate_limited и время последнего вызова (last_call_at, last_call_iso). Обоснование: Runner и Executor из jira-agent опираются на эти поля клиента (пауза между командами, отчёт); без каркаса общие тесты цикла не перенести. JSON-RPC и повторы добавляют задачи 2–3. Цена ошибки: перенос полей в другой класс.

  15. claim шлёт last_call_at, а не last_jira_call_at. Обоснование: хаб (core/api.py) читает body.get("last_call_at", body.get("last_jira_call_at")); новое имя не привязано к Jira. Поля отчёта jira_calls/rate_limited остаются как есть: хаб читает только их. Цена ошибки: если хаб уберёт новое имя, время последнего вызова перестанет доходить (только статус).

  16. Jira-специфичные тесты jira-agent не переносились; test_clients.py написан заново на MiniServer. Исключены: JiraClient (multipart, 429/503), Executor, refresh, история, вложения Jira. Сценарии Http (перенаправления, обрывы, не-JSON, предел fetch_bytes) сохранены на маленьком локальном сервере, фейка Eva в задаче 1 нет (появляется в задаче 2). Обоснование: брифом перенос ограничен общими частями; Jira в агенте нет. Цена ошибки: пропущенный сценарий Http; виден по сравнению со списком тестов jira-agent.

  17. Тесты цикла работают на заглушке wiki_fetch (failed «не реализовано»); паузу и время вызова Eva тесты задают вручную (eva.last_call_at, eva._touch()). Результат done проверяется через подмену Executor.run/do_wiki_fetch. Обоснование: уточнение к задаче 1. Цена ошибки: тесты цикла придётся уточнить, когда команды заработают.

  18. test_run_py_is_copy ищет jira-agent/src/run.py на 1–3 уровня выше корня репозитория; нет файла — skip с причиной. Путь из плана (../../jira-agent) верен для основного checkout, из worktree .wt/<срез> — на уровень глубже. Цена ошибки: тест тихо пропускается на нестандартной раскладке каталогов.

  19. .gitignore дополнен agent.py.new, agent.py.prev, __pycache__/, *.pyc (рабочие файлы самообновления и кэш). Цена ошибки: нет.

  20. EvaClient(cfg) остаётся с сигнатурой каркаса (по всему cfg), а не (cfg_eva, timeout, verify_ssl, ca_bundle) из брифа. Обоснование: Runner/main уже создают клиента как EvaClient(cfg) (решение 14). Цена ошибки: нет, внутренняя сигнатура.

  21. EvaClient.call(method, **kwargs) переопределяет Http.call(method, path, ...). Транспортный вызов — через Http.call(self, ...); тесты общего Http в test_clients.py переведены на agent.Http.call(client, ...). Обоснование: интерфейс клиента Eva задан брифом (метод JSON-RPC первым аргументом). Цена ошибки: путаница сигнатур; видна при чтении класса.

  22. Пауза между вызовами — перед вызовом (и перед повтором) по last_call_at; первый вызов после старта не задерживается. Runner.load_settings ставит eva.pacing из wiki_pacing (по умолчанию 0.2). Обоснование: last_call_at общий с паузой между командами. Цена ошибки: лишняя или недостающая пауза, правится настройкой.

  23. Токен вырезается (***) из текстов HttpError/ConnectionError/EvaError, выходящих из EvaClient. Обоснование: сервер может отразить заголовок в теле ошибки. Цена ошибки: токен в логе.

  24. download принимает только адрес под base (иначе ValueError("чужой хост")) и повторяет те же коды, что call. Обоснование: токен нельзя отправлять на чужой хост (решение 8). Цена ошибки: нет.

  25. classify_links: уточнения разбора (задача 3).

    • Миниатюра определяется по хвосту пути .meta/thumbnail.jpg (query отбрасывается), адрес вложения — путь без хвоста, без query и fragment, процентная кодировка пути сохраняется.
    • Имя: alt/title → текст <a> (пробелы схлопнуты) → unquote последнего сегмента пути.
    • Порядок записей — порядок открывающих тегов (для <a><img></a> сначала ссылка, потом картинка); повторы не склеиваются.
    • Код страницы — первое DOC-<цифры> в пути (регистр не важен, результат в верхнем регистре); /files/… приоритетнее кода.
    • ref — значение после unescape; сверено с хабом: convert.py ищет по el.get("src"/"href") BeautifulSoup, то есть тоже по unescaped-значению (решение 12 подтверждено, расхождений нет).
    • В tests/test_py39.py в белый список stdlib добавлен html (html.parser).

    Обоснование: форматы из Context design хаба. Цена ошибки: потеря/дубль вложения, ловится на живой синхронизации.

  26. Обход дерева wiki_sync ведётся по коду страницы, а не по id (уточнение решения 5).

    • children, seen и parent_code ключуются кодом (ref.code): код уникален и есть в каждой ссылке parent/tree_parent; id берётся только для eva_id.
    • Соседи сортируются по (orderno is None, orderno, name без регистра, code) — как collect_subtree экспорта; решение 5 говорит «name», хвост по коду добавлен для детерминизма.
    • Дерево с проектом — один список документов проекта; без проекта — обход в ширину через tree_parent.code (защита от циклов тоже по коду).

    Обоснование: ссылка Eva в фейке и в экспорте гарантированно несёт code; поле id в ссылке может не совпасть с id документа. Цена ошибки: если коды в Eva неуникальны (не бывает), страницы склеятся.

  27. Страница без названия получает title = code; корень без названия — root_title = code. Обоснование: stage._check хаба отклоняет пустой title (422 <код>: title), и одна такая страница завалила бы всю синхронизацию. Цена ошибки: в хабе страница названа кодом вместо пустого имени.

  28. Страница, исчезнувшая между списком и чтением (get_doc → None), — failed «страница <код> не найдена или нет прав»; частичная пропуск-синхронизация не делается. Обоснование: отчёт codes обязан совпадать со стадией хаба; тихий пропуск скрыл бы потерю. Цена ошибки: из-за одной удалённой на лету страницы нужна повторная синхронизация.

  29. Размер пачки считается тем же кодированием, что у Http.call (ensure_ascii=False, UTF-8) + 2 байта на разделитель; страница больше 4 МБ уходит одна пачкой (предел хаба 16 МБ). Обоснование: оценка совпадает с реальным телом запроса. Цена ошибки: пачка чуть больше 4 МБ; хаб примет до 16 МБ.

  30. fake_hub.POST /agent/wiki/pages проверяет пачку как stage.accept/_check хаба (≤ 50 страниц, command_id целое, команда wiki_sync в статусе claimed, типы и пределы полей, форма attachments/links) и отвечает {error, detail} с 422/404/409. Тесты test_folder.py приведены к валидным страницам и выданной команде. Обоснование: поручение владельца — фейк не мягче хаба. Цена ошибки: фейк строже или мягче хаба в мелочах; ловится e2e.

  31. Решение 26 уточнено (T4.1): ключ дерева — код страницы, но ссылку parent/tree_parent в код разрешает build_children.

    • Ссылка Eva приходит то объектом {id, code, ...}, то строкой-id, то объектом без code (так говорит ref_id эталонного экспорта).
    • build_children строит карту str(id) → code по документам проекта и корню. Ссылка с code даёт код сразу; иначе берётся id (из dict или сама строка) и ищется в карте. Неразрешённая ссылка (родитель в другом проекте) пропускается.
    • Без проекта (фильтр tree_parent.code) разрешение не нужно: родитель известен по запросу.
    • FakeEva: id документов отличны от кодов; ссылки по умолчанию {"id": "<id документа>", "code"}, формы ref_form: full, id_only, string, code_id.

    Обоснование: прежний _ref_code на голой строке-id и dict без code молча давал codes == [root] при done. Цена ошибки: страница с ссылкой на родителя, не попавшего в список проекта, окажется оторванной от дерева (не будет обойдена).

  32. wiki_fetch (задача 5): уточнения.

    • Проверка Content-Length вынесена в Http.fetch_bytes: заявленная длина больше предела — TooBig до чтения тела; без заголовка работает потоковое чтение limit + 1 байт. Предел включительно: ровно 10 МБ принимается.
    • Ошибки: чужой хост (ValueError из download) → «чужой хост»; TooBig → «файл больше 10 МБ — откройте в Eva» (как в спеке хаба); HTTP 404 → «файл не найден в Eva»; перенаправление на другой адрес → «Eva перенаправила скачивание на <хост>»; прочее — общие правила Executor.run (5xx и сеть повторяются).
    • Пустой файл (0 байт): хаб принимает его от агента (POST /agent/files → files.put(allow_empty=True), решение 13 хаба; check_result сверяет размер 0 с files.size, None — только «нет файла»), поэтому агент отчитывается done {file, size: 0} без особых случаев. Расхождения с брифом нет; fake_hub повторяет это и добавил 413 для файла > 10 МБ, как files.put.
    • Результат {attachment_id, file, size}; size берётся из ответа хаба. Содержимое в лог не пишется (только размер); в памяти одна копия данных, base64 строится на время отправки.
    • Тесты каркаса (test_runner, test_folder), опиравшиеся на заглушку wiki_fetch, ждут теперь «чужой хост» (адрес фикстуры не на хосте Eva).

    Обоснование: сверка с кодом хаба (attachments.check_result, api._put_file) и спекой «Вложения вики по запросу». Цена ошибки: другой текст ошибки в карточке вложения; ловится живой проверкой.

  33. check (задача 6): код возврата 0 или 3, оба узла проверяются всегда.

    • Хаб — GET /agent/settings, Eva — CmfDocument.list(filter=[], fields=["code"], slice=[0, 1]); вывод «хаб: доступен…», «eva: ok» или «eva: НЕДОСТУПНА / ошибка — <текст>». Недоступность хаба не мешает проверить Eva.
    • Код недоступности — 3 (design 10), а не 1, как у каркаса из jira-agent; selftest по-прежнему 0 или 3. Тесты каркаса приведены: test_check_command и test_check_folder_silent_relay ждут 3; FolderIntegrationTest и SelfUpdateE2E поднимают FakeEva (selftest = check, без доступной Eva самообновление откладывается).
    • У Eva в check одна попытка (ATTEMPTS = 1): проверка отвечает сразу, без повторов с паузами 1+2+4 с. Токен Eva в тексте ошибки заменяется на *** (решение 23).

    Обоснование: design 10 и поручение владельца «коды как у jira-agent (0 или 3)». Цена ошибки: скрипт установки, ожидающий 1, увидит 3; на самообновление не влияет (там selftest).

  34. Флаг agent.py run --once для сквозного прогона. Ставит runner.idle_limit = 1: агент выполняет выданное и выходит при первом пустом claim (код 0). Протокол с хабом не менялся, Runner.idle_limit уже был в каркасе (используют тесты). Обоснование: прогон делает две фазы (wiki_sync, затем wiki_fetch, который клиент ставит после чтения страницы), агенту нужен выход между ними. Цена ошибки: нулевая для боевого режима (без флага поведение прежнее). Пустой claim хаб держит ~30 с, поэтому каждая фаза e2e длится около 30 с.

  35. Сквозной прогон: scripts/e2e-hub.sh + scripts/fake_eva_serve.py.

    • Хаб — uv run --project <jira-hub/.wt/wiki> --no-sync jira-hub serve --host 127.0.0.1 --port <свободный> с JIRA_HUB_HOME=$(mktemp -d)/hub-home; порт 8790 отвергается явно. --no-sync, чтобы прогон не менял .venv соседнего worktree.
    • Токены берутся из jira-hub tokens (клиентский и «агент wiki»); транспорт агента — прямой HTTP, auto_update=false.
    • Фейковая Eva (tests/fake_eva.py) запускается отдельным процессом, 5 страниц в проекте E2E, картинка /files/obj/e2e/scheme.png; проект создаётся POST /projects {label, name, wiki_root}, затем wiki_sync, GET /wiki/status|tree|pages/DOC-2, POST /wiki/attachments/<id>/fetch, wiki_fetch, GET …/content и сверка sha256.
    • Завершение: trap останавливает дерево процессов (uv run порождает хаб дочерним процессом: простой kill оставлял хаб жить) с kill -9 как запасным вариантом; временный каталог удаляется. Готовность хаба — опрос порта nc -z с шагом 0,1 с.
    • Хаб принял всё, что требует его спека (расхождений с хабом не найдено).

    Обоснование: поручение владельца (тестовый хаб только из worktree wiki, временный home, не живой). Цена ошибки: при переименовании модулей/маршрутов хаба прогон упадёт на POST /projects или tokens — это сигнал, а не порча данных.

  36. classify_links не падает на нестандартном HTML и битых адресах (I-1).

    • На Python 3.9 _markupbase зовёт error() на <![foo]>, <![ ]> и подобном, а базовый error() бросает NotImplementedError. _LinkParser.error() бросает своё _ParseError, а parse_marked_section ловит его и пропускает конструкцию до > (как «мусорный комментарий»; нет > — ждёт данных, при close() её пропускает goahead). Известные секции (<![if …]>, <![endif]>, <![CDATA[…]]>) разбираются как раньше.
    • Простой no-op error() не годится: после него parse_marked_section падает на несвязанной переменной match, а _scan_name — на распаковке None.
    • Запасной путь: feed/close обёрнуты в try (_ParseError, NotImplementedError, AssertionError), собранное до сбоя сохраняется, в лог — строка.
    • Ссылка, на которой urljoin/urlsplit бросают ValueError (http://[::1), пропускается, остальные остаются.

    Обоснование: одна битая конструкция в HTML страницы роняла весь wiki_sync «внутренней ошибкой агента». Цена ошибки: ссылка внутри нераспознанной секции не попадёт в attachments/links (останется в HTML как есть).

  37. wiki_fetch: адрес кодируется перед запросом, отказ по хосту — ForeignHost (I-2).

    • Путь и запрос после base_url кодируются quote(…, safe="/%:@!$&'()*+,;=-._~") (у запроса ещё ?); готовые %XX не кодируются повторно, одиночный % без двух шестнадцатеричных цифр становится %25; фрагмент #… отбрасывается.
    • download бросает ForeignHost("чужой хост") (другие схема, хост, порт, userinfo, адрес не под base_url) или ForeignHost("некорректный адрес") (urlsplit не разбирает: http://[::1, порт не числом). do_wiki_fetch ловит только ForeignHost; прочие ValueError (в том числе UnicodeEncodeError) уходят в «внутреннюю ошибку агента», а не маскируются под «чужой хост».
    • url вложения в classify_links не перекодируется: это ключ склейки вложений в хабе, менять его форму — вне MVP.

    Обоснование: имена файлов в Eva с пробелами и кириллицей; без кодирования http.client отказывал (InvalidURL повторялся как обрыв связи, UnicodeEncodeError выдавался за «чужой хост»). Цена ошибки: если Eva ждёт путь в другой кодировке (не UTF-8), файл не найдётся (404 «файл не найден в Eva»).

  38. EvaError в команде — failed «Eva: <текст>» без повтора (M-1). Текст уже очищен от токена (решение 23). Обоснование: ошибка JSON-RPC (нет прав, неверный фильтр) — ответ Eva, а не сбой агента; повтор не поможет. Цена ошибки: временная ошибка Eva, пришедшая в error, не повторится автоматически — повторяет пользователь.

  39. 413 хаба на пачку страниц — ошибка с кодом страницы или границами пачки (M-3). «хаб: HTTP 413: страница DOC-x больше 16 МБ — хаб не принял: <ответ хаба>» для пачки из одной страницы; «пачка страниц DOC-a…DOC-b (N шт.) больше 16 МБ …» иначе. Не повторяется. Пачка из нескольких страниц больше 16 МБ невозможна (пачка закрывается на 4 МБ), но текст на этот случай есть. Обоснование: по «HTTP 413» без кода не понять, какую страницу Eva чистить. Цена ошибки: нет.

  40. list_docs останавливается, если ответ страницы не приносит новых записей (M-4). Записи склеиваются по JSON-представлению (sort_keys); полная страница без новых записей — останов с записью в лог, собранное возвращается. Так ловятся оба случая «Eva игнорирует slice»: весь список каждый раз и одна и та же первая страница. Обоснование: иначе бесконечный цикл вызовов Eva. Цена ошибки: если Eva игнорирует slice и отдаёт только первые N записей, дерево будет неполным без явного отказа (видно в логе).

  41. Executor(eva, hub): удалены неиспользуемые Executor.s (настройки) и Executor.sleep (M-7). Настройки, нужные исполнителю (wiki_pacing), ставит Runner.load_settings прямо в EvaClient.pacing. Цена ошибки: нет.

  42. Перенаправление Eva на SSO (M-5) не чинится до живой проверки. Сейчас перенаправление на другой адрес отклоняется (RedirectAway → «перенаправление … отклонено»), страница входа с кодом 200 даёт «ответ не JSON». Как именно Eva банка отвечает на протухший токен, неизвестно; чинить вслепую — угадывать формат. В README и ARCHITECTURE строка «проверить при первой живой синхронизации». Цена ошибки: при протухшем токене текст ошибки менее понятен («ответ не JSON» вместо «токен Eva недействителен»).

  43. Документация (I-3, T6.1, T6.4, M-2, M-6).

    • Выкатка — jira-hub agent publish --module wiki src/agent.py (без --module хаб публикует версию агенту jira, registry.DEFAULT_AGENT); порог — wiki_min_agent_version (ключ модуля wiki хаба, agent_min_version_key).
    • eva.base_url — только origin: к нему добавляются /api/, /files/…, /project/Document/<код>, и с ним же сравнивается адрес вложения. agent.example.json уже содержит origin без пути (https://eva.example.ru); JSON без комментариев, поэтому пояснение — в README.
    • Агент — только macOS (fcntl в acquire_lock): из README убраны C:\… и «планировщик задач», добавлен пример launchd с меткой ru.aepif.eva-agent по образцу jira-agent.

    Цена ошибки: нет.

  44. Финальная волна правок — один fixer, объём: I-1, I-2, I-3, M-1–M-4, M-6, M-7, T6.1, T6.4, T5.2; M-5 не чинится (R-14). Обоснование: M-5 (отказ на text/html) мог бы ложно отвергнуть настоящие .html-вложения; поведение Eva при просроченном токене проверяется на первой живой синхронизации. Цена ошибки: при SSO-редиректе страница входа сохранится как вложение; правится одной проверкой Content-Type.

  45. T2.3 и T5.1 отклонены по итогам финального ревью (R-15). Http.__init__ делает base.rstrip("/"); размер из ответа хаба равен len(decoded), и хаб сверяет его с files.size. Цена ошибки: нет (оба подтверждены ревьюером по коду).