106 lines
12 KiB
Markdown
106 lines
12 KiB
Markdown
## 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; 87 `ResourceWarning: unclosed database` (test_queue, test_relay, test_commands, test_importer).
|
||
|
||
## 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` и пустая строка по-прежнему равны. Метки и прочие поля — без изменений.
|
||
12. **Реле**: `_cleaned` хранит `Path.resolve()`; проверка — по `self.root.resolve()`. Ошибка `resolve` (папки нет) —
|
||
сравнение по исходному пути, как сейчас.
|
||
|
||
### Тесты и чистота
|
||
|
||
13. Новые тесты: bool в строковом параметре и `[]` в списочном (`test_commands`); `sync_run.finished_at` не ставится
|
||
при `failed` не-refresh по пределу выдач (`test_queue`); 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/`] лишний файл живёт только между переименованием и записью манифеста; при падении
|
||
процесса посреди — остаётся; следующая публикация его перезапишет. → не влияет на `load` (читается только `agent.py`).
|
||
- [«неизвестно» до первого 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 предупреждений прячут настоящие утечки | время на правку фикстур |
|