Andrey Epifancev 0cf71e7792 Merge branch 'main' into feat/32-hub-hygiene
Конфликт pyproject.toml: оставлены обе секции — filterwarnings (hub-hygiene-d, решение 14)
и [tool.uv] python-preference (#29). Рулинги R-14 (переписан), R-15, R-16 в design.md.
318 passed, 0 warnings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 11:01:16 +04:00

117 lines
16 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.

## 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<N>», «без запускателя» (агент сообщил `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 |