2026-10-15 10:23:58 +04:00

15 KiB
Raw Blame History

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).

Статус агента

  1. 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).
  2. MCP agent_status: строка запускателя — «запускатель: v», «без запускателя» (агент сообщил null) или «запускатель: неизвестно (агент ещё не выходил на связь после перезапуска хаба)» (agent_reported = false). Если поля agent_reported в ответе нет (старый хаб) — прежнее поведение.

Настройки, очередь, подтверждение, реле

  1. PUT /settings отвечает settings.get_all(conn) (токены скрыты) после записи — те же поля, что GET /settings, включая settings_version. Клиенты, читающие settings_version из ответа (client, mcp_server.settings_set, web), не меняются.
  2. Пропуск зависимой команды: если среди неразрешённых ключей есть ключ, чья ensure_issue в cancelled, текст — «создание задачи new:N отменено» (по первому такому ключу); иначе прежний «задача new:N не создана».
  3. Сравнение строк в check_confirmed: для полей ensure_fields со строковыми значениями сравнение после замены \r\n на \n с обеих сторон; None и пустая строка по-прежнему равны. \r\n нормализуется во всех строковых полях ensure_fields. Метки и прочие поля — без изменений.
  4. Реле: _cleaned хранит Path.resolve(); проверка — по self.root.resolve(). Ошибка resolve (папки нет) — сравнение по исходному пути, как сейчас.

Тесты и чистота

  1. Новые тесты: 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-подтверждение; реле с симлинком на ту же папку.
  2. ResourceWarning: фикстуры и хелперы тестов закрывают соединения (conn.close() / contextlib.closing); критерий — uv run pytest -W error::ResourceWarning без предупреждений unclosed database (pytest filterwarnings = error::ResourceWarning в pyproject.toml, если не ломает сторонние пакеты; иначе — проверка в плане командой).
  3. 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