Andrey Epifancev 9320de3adf Решения 44–45: объём финальной волны, отклонённые T2.3 и T5.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 06:06:29 +04:00

280 lines
38 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

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

# 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`.
Цена ошибки: нет (оба подтверждены ревьюером по коду).