## Context Хаб `main` 8229bba (web-app и agent-v9-hardening слиты). Issue aep/jira-hub#32. Источники: #25 (M-1…M-3, M-5…M-7), #16 (T3.1, T3.2, T5.2, M-8, ре-ревью minor-1, minor-3, O-9, O-10, O-11), #4 (M-9/O-4, M-10). Сверка с кодом — 15.10.2026. Найдено при чтении кода: - `release._write_atomic` — `tmp.write_bytes` + `os.replace`, без `fsync` и без очистки `.tmp` при ошибке; `publish` пишет `agent.py`, затем манифест. `cli` ловит только `PublishError`. Если манифест не записался, новый `agent.py` не сходится со старым манифестом по sha → `load` → `None` → 404. - `release.current`: `int(m["version"])` (6.9 → 6, `true` → 1), `str(m["sha256"])`; `log.warning` на каждый вызов (каждый `claim` и `GET /agent`). - `release.load`: `data.decode("utf-8")` после сверки sha, `UnicodeDecodeError` не ловится → 500. - `Hub.agent_update = {"update_state": None, "launcher_version": None}` — в памяти; после перезапуска хаба до первого `claim` `launcher_version = None` неотличимо от «агент без запускателя» (`release.hint`, `mcp_server.fmt_agent:115`). - `h_settings_put` отвечает `{"settings_version": version}`. - `queue._next`: при неразрешённом временном ключе — `skipped` «задача new:N не создана», независимо от того, `failed` или `cancelled` команда-создатель (`_definer_status`). - `commands.check_confirmed`: `(issue.get(field) or None) != (p[field] or None)` — `\r\n` в зеркале против `\n` в параметре даёт «не совпало». - `relay._cleaned: set[Path]`, проверка `self.root not in self._cleaned` — по написанию пути. - Тесты: 249 passed; 88 `ResourceWarning: unclosed database` (test_queue 37, test_commands 18, test_mirror 15, test_db 6, test_importer 6, test_settings 6; в test_relay их нет). ## Goals / Non-Goals **Goals:** публикация не оставляет хаб без годной версии при сбое записи и не роняет команду трейсбеком; статус агента не утверждает неизвестное; подтверждение по состоянию работает на задачах с CRLF; ответы и тексты называют то, что произошло; тесты закрывают найденные пробелы и чисты от `ResourceWarning`. **Non-Goals:** см. proposal. ## Decisions ### Публикация агента 1. **Запись файла** (`_write_atomic`): временный файл `.<имя>.tmp` в том же каталоге, `write` + `flush` + `os.fsync`, `os.replace`; при `OSError` временный файл удаляется (ошибки удаления игнорируются), исключение пробрасывается. 2. **Порядок публикации:** `agent.py` → `agent.py.prev` (`os.replace`, если `agent.py` есть); новый файл через `_write_atomic` в `agent.py`; манифест через `_write_atomic`. `OSError` после переименования — `agent.py.prev` возвращается на место `agent.py` (`os.replace`; если и это не удалось — в лог), и `PublishError("не удалось записать публикацию: <ошибка>")`. После успешной записи манифеста `agent.py.prev` удаляется. Итог: при любом сбое опубликована прежняя версия (манифест и `agent.py` согласованы), `release/` без временных файлов. 3. **`cli`** печатает текст `PublishError` в stderr и возвращает 1 — как сейчас; новых веток нет, `OSError` до `cli` не доходит. 4. **Годный манифест** (`current`): объект; `version` — `int` и не `bool`, ≥ 1; `sha256` — строка, `re.fullmatch(r"[0-9a-f]{64}")`. Иначе — «публикации нет». Совпадает с проверкой агента v9 (design agent-v9-hardening 10). 5. **Предупреждение о битом манифесте** — один раз на каждый новый текст ошибки: модульная переменная с последним текстом; успешное чтение или отсутствие манифеста её сбрасывает. Отсутствие манифеста — не ошибка, без лога. 6. **`load`**: `UnicodeDecodeError` → `log.error` «agent.py не в UTF-8 — публикации нет» и `None` (404 `no_release`). ### Статус агента 7. **`agent_reported`**: `Hub.agent_update` получает ключ `reported: False`; `note_agent_update` (вызывается на каждом `claim`) ставит `True`. `GET /agent` отдаёт `agent_reported`. `release.hint(...)` получает признак и не выдаёт «агент запущен без run.py…», пока `agent_reported = false`; подсказки об откате и отказе — по `update_state` как сейчас (до первого `claim` он `null`). 8. **MCP `agent_status`**: строка запускателя — «запускатель: v», «без запускателя» (агент сообщил `null`) или «запускатель: неизвестно (агент ещё не выходил на связь после перезапуска хаба)» (`agent_reported = false`). Если поля `agent_reported` в ответе нет (старый хаб) — прежнее поведение. ### Настройки, очередь, подтверждение, реле 9. **`PUT /settings`** отвечает `settings.get_all(conn)` (токены скрыты) после записи — те же поля, что `GET /settings`, включая `settings_version`. Клиенты, читающие `settings_version` из ответа (`client`, `mcp_server.settings_set`, web), не меняются. 10. **Пропуск зависимой команды**: если среди неразрешённых ключей есть ключ, чья `ensure_issue` в `cancelled`, текст — «создание задачи new:N отменено» (по первому такому ключу); иначе прежний «задача new:N не создана». 11. **Сравнение строк в `check_confirmed`**: для полей `ensure_fields` со строковыми значениями сравнение после замены `\r\n` на `\n` с обеих сторон; `None` и пустая строка по-прежнему равны. `\r\n` нормализуется во всех строковых полях `ensure_fields`. Метки и прочие поля — без изменений. 12. **Реле**: `_cleaned` хранит `Path.resolve()`; проверка — по `self.root.resolve()`. Ошибка `resolve` (папки нет) — сравнение по исходному пути, как сейчас. ### Тесты и чистота 13. Новые тесты: bool в строковом параметре и `[]` в списочном (`test_commands`); `sync_run.finished_at` не ставится при `failed` не-refresh по пределу выдач (`test_queue`; проверка T5.2 буквально невозможна, тест проверяет соседний эффект, как в плане); 501 на `DELETE`/`PATCH` (`test_api`); `epics.csv` без «Ключ» (`test_importer`); «подготовлена заново» в `test_vanished_tree_keeps_work` (`test_relay`); предупреждение «обновите агента» при `agent_version < min_agent_version`, офлайн-агент (нет контакта дольше порога) в `GET /agent`, `tick` — проверка эффекта (`test_api`/`test_queue`); публикация — сбой записи манифеста возвращает прежний `agent.py`, `.tmp` нет; битый манифест (`6.9`, `true`, короткий sha) — «публикации нет», предупреждение один раз; не-UTF-8 → 404; `agent_reported` до и после `claim`; `PUT /settings` отдаёт `relay_dir`; текст «отменено»; CRLF-подтверждение; реле с симлинком на ту же папку. 14. **`ResourceWarning`**: фикстуры и хелперы тестов закрывают соединения (`conn.close()` / `contextlib.closing`); критерий — `uv run pytest -W error::ResourceWarning` без предупреждений `unclosed database` (pytest `filterwarnings = error::ResourceWarning` в `pyproject.toml`, если не ломает сторонние пакеты; иначе — проверка в плане командой). 15. **T3.1**: `_type_error` переписывается без тяжёлой строки с `and` и кортежем — отдельные ветки по типу. ## Risks / Trade-offs - [`agent.py.prev` в `release/`] лишний файл живёт только между переименованием и записью манифеста; при падении процесса посреди — остаётся; следующая публикация его перезапишет. → при падении процесса между переименованием и записью `agent.py` нет вовсе, и `GET /agent/release` отдаёт 404 до следующей публикации (как и прежде при падении между записью agent.py и манифеста). - [«неизвестно» до первого claim] до первого `claim` агента (обычно до 30 с) после перезапуска хаба статус неполный. → честнее ложного «без запускателя». - [`PUT /settings` отдаёт больше полей] клиенты, ожидавшие ровно `{settings_version}`, получают надмножество. → поле на месте. - [Конфликт с #29] текстовый в `tests/test_api.py` (одна строка у #29). → кто сливается вторым, разрешает. ## Decisions made without discussion | Маркер | Дата | Решение | Обоснование | Цена ошибки | |---|---|---|---|---| | R-1 (design #7) | 2026-10-15 | Признак `agent_reported` в памяти, а не `launcher_version` в БД | без записи на каждый claim; значение из БД после перезапуска могло устареть | до 30 с «неизвестно» после перезапуска хаба | | R-2 (design #2) | 2026-10-15 | Откат публикации через `agent.py.prev` (переименования, без копии) | переименование не требует места на диске — откат работает и при полном диске | лишний файл в `release/` при падении процесса посреди публикации | | R-3 (design #9) | 2026-10-15 | `PUT /settings` отвечает всеми настройками, а не `{settings_version, relay_dir}` | один формат с `GET /settings`; O-11 — «не видно, что записалось» относится к любой настройке | надмножество полей в ответе | | R-4 (design #10) | 2026-10-15 | «Отменено» — только при `cancelled` создателе; при `failed` текст прежний | O-10 описывает только force-отмену; `failed` — задача действительно не создана | нет | | R-5 (design #14) | 2026-10-15 | Цель — ноль `ResourceWarning` от sqlite в тестах, проверка `-W error::ResourceWarning` | M-9/O-4: 87 предупреждений прячут настоящие утечки | время на правку фикстур | | R-6 (design #14) | 2026-10-15 | В `filterwarnings` два фильтра: `error::ResourceWarning` и `error::pytest.PytestUnraisableExceptionWarning` | утечка соединения рождается в `__del__`, одного ResourceWarning pytest не ловит | утечка останется незамеченной | | R-7 (design #14) | 2026-10-15 | Соединения закрываются фикстурами/хелперами (conftest `open_db`), а не autouse-подменой `db.connect` | подмена спрятала бы будущие утечки в `src/` | больше правок в тестах | | R-8 (design #2) | 2026-10-15 | При сбое самой первой публикации (прежнего `agent.py` нет) новый `agent.py` удаляется, `release/` остаётся без публикации | `agent.py` без манифеста даёт 404 так же, но оставляет мусор | пустой `release/` вместо непарного файла | | R-9 (design #2) | 2026-10-15 | `agent.py.prev` удаляется после любой успешной публикации | файл нужен только для отката | нет | | R-10 (design #2) | 2026-10-15 | `secure_dir` при публикации вызывается внутри `try` | его `OSError` тоже должен стать `PublishError`, а не трейсбеком | трейсбек в CLI | | R-11 (design #7) | 2026-10-15 | У `release.hint(..., reported=True)` значение по умолчанию | не переписывать старые вызовы и тесты | забытый вызов без признака даёт прежнее поведение | | R-12 (design #5) | 2026-10-15 | Запоминание последнего текста ошибки манифеста (`_warned`) под `threading.Lock` | `current` вызывается из потоков HTTP-сервера | двойное предупреждение при гонке | | R-13 (design #14) | 2026-10-15 | Первый абзац `CLAUDE.md` («текущий change — …») не менять | его правит параллельная ветка #29 (`feat/29-web-app-followups`), конфликт гарантирован; строка перепишется при архивации | до архивации `CLAUDE.md` в ветке называет чужой change. Итог: после слияния #29 первый абзац `CLAUDE.md` в main общий («активные change — `openspec/changes/` (кроме `archive/`)»), поэтому при архивации его переписывать не нужно | | R-14 (merge) | 2026-10-15 | Контроллер main в ветку не сливал; после слияния #29 (main 55886bd) сессия влила main в ветку до PR, конфликт `pyproject.toml` (блок `[tool.uv]` из #29 против `filterwarnings`) разрешён: оставлены обе секции | PR без конфликта, Gitea не разрешает конфликты в интерфейсе; после слияния 318 passed, 0 warnings | merge-коммит в истории ветки | | R-15 (design #2) | 2026-10-15 | T2-I1 (откат удалял прежний `agent.py` при сбое до переименования в `.prev`) исправлен в рамках задачи 2: `existed = agent.exists()` до `try`, новый `agent.py` удаляется, только если прежнего не было | потеря опубликованной версии недопустима даже между задачами | нет | | R-16 (план) | 2026-10-15 | Дешёвые minor ревьюеров задач и финального ревью (T1.1, T1.2, T2.3, T4.1, M-1…M-5) исправлены одной волной перед PR, а не отложены | правки малы и в рамках спеки | чуть больше diff в PR |