15 KiB
15 KiB
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}— в памяти; после перезапуска хаба до первогоclaimlauncher_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
Публикация агента
- Запись файла (
_write_atomic): временный файл.<имя>.tmpв том же каталоге,write+flush+os.fsync,os.replace; приOSErrorвременный файл удаляется (ошибки удаления игнорируются), исключение пробрасывается. - Порядок публикации:
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/без временных файлов. cliпечатает текстPublishErrorв stderr и возвращает 1 — как сейчас; новых веток нет,OSErrorдоcliне доходит.- Годный манифест (
current): объект;version—intи неbool, ≥ 1;sha256— строка,re.fullmatch(r"[0-9a-f]{64}"). Иначе — «публикации нет». Совпадает с проверкой агента v9 (design agent-v9-hardening 10). - Предупреждение о битом манифесте — один раз на каждый новый текст ошибки: модульная переменная с последним текстом; успешное чтение или отсутствие манифеста её сбрасывает. Отсутствие манифеста — не ошибка, без лога.
load:UnicodeDecodeError→log.error«agent.py не в UTF-8 — публикации нет» иNone(404no_release).
Статус агента
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).- MCP
agent_status: строка запускателя — «запускатель: v», «без запускателя» (агент сообщилnull) или «запускатель: неизвестно (агент ещё не выходил на связь после перезапуска хаба)» (agent_reported = false). Если поляagent_reportedв ответе нет (старый хаб) — прежнее поведение.
Настройки, очередь, подтверждение, реле
PUT /settingsотвечаетsettings.get_all(conn)(токены скрыты) после записи — те же поля, чтоGET /settings, включаяsettings_version. Клиенты, читающиеsettings_versionиз ответа (client,mcp_server.settings_set, web), не меняются.- Пропуск зависимой команды: если среди неразрешённых ключей есть ключ, чья
ensure_issueвcancelled, текст — «создание задачи new:N отменено» (по первому такому ключу); иначе прежний «задача new:N не создана». - Сравнение строк в
check_confirmed: для полейensure_fieldsсо строковыми значениями сравнение после замены\r\nна\nс обеих сторон;Noneи пустая строка по-прежнему равны.\r\nнормализуется во всех строковых поляхensure_fields. Метки и прочие поля — без изменений. - Реле:
_cleanedхранитPath.resolve(); проверка — поself.root.resolve(). Ошибкаresolve(папки нет) — сравнение по исходному пути, как сейчас.
Тесты и чистота
- Новые тесты: 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-подтверждение; реле с симлинком на ту же папку. ResourceWarning: фикстуры и хелперы тестов закрывают соединения (conn.close()/contextlib.closing); критерий —uv run pytest -W error::ResourceWarningбез предупрежденийunclosed database(pytestfilterwarnings = error::ResourceWarningвpyproject.toml, если не ломает сторонние пакеты; иначе — проверка в плане командой).- 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 предупреждений прячут настоящие утечки | время на правку фикстур |
| 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 |