Andrey Epifancev d427bf3584 docs(openspec): agent-v9-hardening — спека запускателя v2 и агента v9 (#26)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 23:52:00 +04:00

18 KiB
Raw Blame History

Context

Агент v8 (= код v5) работает на виртуалке под run.py v1 (launchd, KeepAlive), транспорт — общая папка UTM. Код: хаб main 3d13f2d, агент main d364cee. Issues: aep/jira-hub#26 (эта дельта спеки), aep/jira-agent#23 (код). Источник находок: aep/jira-agent#22 (M-3…M-7, M-10 финального ревью agent-self-update), #18 (M-2, M-3, M-5, M-6 пакета C), #4 (M-10 — точность фейков).

Найдено при чтении кода:

  • run.py решает «пробный период или нет» только по update.json.state == "trial" (Launcher.run), sha256 файла agent.py не сверяет. update.json.sha256 равен sha256 файла версии: агент пишет в agent.py.new ровно байты content, sha256 которых проверен.
  • apply_update: копия agent.py → agent.py.prev.tmp → agent.py.prev, затем os.replace(new, agent), затем write_state(trial). Между двумя последними шагами update.json = staged, agent.py.new уже нет: при перезапуске recover_files ничего не делает, новая версия работает без пробного периода, confirm() агента (только из trial) ok не пишет — staged навсегда (M-5).
  • rollback: os.replace(prev, agent), затем write_state(rolled_back). Падение между ними оставляет trial при прежней версии в agent.py и без agent.py.prev: каждые 10 минут SIGTERM, «откат невозможен», перезапуск (M-3).
  • wait_child после таймаута сразу возвращает «истёк»; ответ claim, пришедший за секунду до SIGTERM, успевает записать ok, но откат всё равно делается (M-4).
  • cmd_run при занятой блокировке журнала возвращает 1 — как любое падение; в trial это откат здоровой версии, если журнал держит осиротевший агент (M-7).
  • Updater.prepare пишет agent.py.new.tmp, делает chmod, os.replace, запускает selftest — OSError (диск полон, нет прав) не ловится: трейсбек, код 1, перезапуск, снова prepare (M-6). В run.py OSError в apply_update роняет запускатель; launchd поднимает его, recover_files снова вызывает apply_update — цикл.
  • Updater.check делает str(release.get("sha256")): None превращается в "None", подготовка каждый раз получает «публикация сменилась между запросами» — временный сбой каждые 5 минут навсегда (M-10). Хаб сам такого не отдаёт (release.current возвращает str(m["sha256"])), но агент не должен зависеть от этого.
  • restore_pace при времени журнала в будущем пишет предупреждение и затем «последний вызов Jira не позже <будущее>» (M-2 #18).
  • do_refresh вызывает hub.known_keys() и hub.state(); их ConnectionError уходит в Executor.run как обрыв связи с Jira: retryable, лог «обрыв связи с Jira», итог «результат неизвестен (обрыв связи с Jira)…: не ответил через папку …» (M-6 #18).
  • Фейки: уже как в живой Jira — 400 на key in (...) с несуществующим ключом (fake_jira.py:154), ответ agile без fields у задачи без спринта (:251), PUT с null очищает поле. Не как в Jira: поиск дубля (reporter = currentUser()) не учитывает project = и created >= -7d; fake_hub отвечает на claim сразу, а настоящий хаб держит его до 30 с при пустой очереди (api.h_claim, claim_wait = 30).

Goals / Non-Goals

Goals: запускатель не откатывает здоровую версию и не оставляет новую без пробного периода ни при каком месте падения; нехватка места не роняет ни агента, ни запускатель; неверная публикация не даёт бесконечных сбоев; тексты ошибок называют настоящий источник; фейки повторяют наблюдённое поведение Jira и хаба там, где агент от него зависит.

Non-Goals: см. proposal.

Decisions

Запускатель run.py v2

  1. Сверка состояния (reconcile) при старте вместо recover_files. Сначала удаляются agent.py.new.tmp, agent.py.prev.tmp, update.json.tmp; если agent.py нет, а agent.py.prev есть — восстановление, как сейчас. Затем по update.json (S — его sha256, A — sha256 файла agent.py):

    state A agent.py.new Действие
    staged = S любое падение после подмены (M-5): agent.py.new удалить, записать trial
    staged ≠ S есть, sha256 = S подмена (решение 4)
    staged ≠ S нет или sha256 ≠ S agent.py.new удалить, rejected «подготовленный файл потерян»
    trial = S — без изменений (пробный период начнётся при запуске)
    trial ≠ S — устаревший пробный период (M-3): rolled_back «пробный период прерван: agent.py не v»; отката файла нет
    иное или нет файла — есть agent.py.new удалить

    Запись rolled_back (а не ok) при устаревшем trial: агент эту версию больше не ставит — так и нужно, если прерван откат; если agent.py подменили руками, вернуть версию можно публикацией с номером выше (решение владельца).

  2. Пробный период перед каждым запуском включается, только если update.json.state = trial и sha256 agent.py равен update.json.sha256. Иначе — та же сверка, что при старте (строка trial/≠ S). sha256 считается на каждом запуске агента: файл ~60 КБ, запуски редки.

  3. Повторная проверка ok (M-4). После истечения 10 минут и остановки агента (SIGTERM, через 60 с SIGKILL) запускатель перечитывает update.json: ok — отката нет, лог «подтверждение пришло в последний момент», та же версия запускается без паузы; иначе — откат, как сейчас.

  4. Подмена с OSError (M-6). Шаги подмены прежние. OSError до os.replace(new, agent) — удалить agent.py.prev.tmp и agent.py.new, записать rejected «подмена не удалась: <ошибка>», запустить текущий agent.py. OSError при записи trial после замены — немедленно вернуть agent.py.prev на место (переименование, места не требует), попытаться записать rejected; если не удалось — при следующем старте сверка увидит staged / ≠ S / нет agent.py.new → rejected. rejected навсегда для этой sha256, повтора нет: у запускателя нет таймера, повтор ушёл бы в цикл; лечится освобождением места и публикацией v+1 (решение владельца).

  5. Запись update.json в запускателе не роняет его: OSError — лог «не удалось записать update.json: …», работа продолжается. Откат (os.replace(prev, agent)) места не требует.

  6. Код 73 (M-7). Выход агента с 73 — «журнал занят другим процессом»: не сбой версии. Отката нет даже в trial, лог «агент уже запущен другим процессом (журнал занят), повтор через N с», перезапуск с обычной нарастающей паузой 5…300 с. Чужие процессы запускатель не трогает. В trial пробный период начинается заново при каждом запуске.

  7. LAUNCHER_VERSION = 2; переменная JIRA_AGENT_LAUNCHER = 2 уходит хабу в claim как launcher_version.

Агент v9

  1. Код 73. cmd_run при занятой блокировке журнала возвращает EXIT_LOCKED = 73 (EX_CANTCREAT, рядом с 75 EX_TEMPFAIL) с прежним текстом «агент уже запущен…». prune при занятой блокировке — по-прежнему 1: его запускатель не вызывает.
  2. OSError в подготовке. В Updater.prepare запись agent.py.new.tmp, os.replace, запуск selftest и запись staged обёрнуты: OSError — временный сбой (_transient: удалить agent.py.new и .tmp, повтор не раньше 5 мин), лог «обновление до v отложено на 5 мин: файлы: <ошибка>». _drop_new терпит любой OSError. Запись rejected с OSError — только лог: агент работает дальше.
  3. Формат agent_release. Updater.check принимает публикацию, только если version — целое (не bool) и sha256 — строка из 64 символов [0-9a-f]. Иначе — False и лог «хаб прислал неполную публикацию» не чаще раза в час (тот же таймер, что у подсказки про run.py).
  4. Время журнала в будущем (M-2 #18). restore_pace пишет одну строку «журнал: время последней записи в будущем (<время>) — пауза перед первой командой полная» и не пишет вторую.
  5. Источник ошибки в refresh (M-6 #18). Вызовы хаба в do_refresh (known_keys, state) оборачиваются: ConnectionError и HttpError хаба превращаются в HubError с текстом «хаб: <исходный текст>». Executor.run отдаёт по ней failed с этим текстом и признаком источника «хаб»; retryable — для ConnectionError и HttpError ≥ 500 (повтор refresh безопасен: в Jira он не пишет). Runner берёт источник в текст: лог «обрыв связи с хабом, повтор k/3…», итог «результат неизвестен (обрыв связи с хабом), повтор безопасен: хаб: …». Для ошибок Jira тексты прежние.
  6. M-5 #18. int(resp.get("status") or 0) в FolderTransport._take заменяется на уже проверенные status, body.
  7. AGENT_VERSION = 9: номера 6–8 заняты живой проверкой 14.10.2026 (опубликована v8), publish принимает только версию выше опубликованной.

Фейки и тесты

  1. fake_jira.search_keys учитывает project = <KEY> (префикс ключа) и created >= -<N>d относительно часов фейка now (по умолчанию текущее время); add() по умолчанию ставит created = now − 1 ч, POST /issue — created = now. Тесты: задача старше 7 дней и задача чужого проекта не считаются дублем в ensure_issue.
  2. fake_hub.claim_wait (по умолчанию 0): при пустой очереди фейк держит claim до claim_wait секунд, как хаб. Тест: остановка агента во время долгого claim — агент выходит после ответа, без команды и без записи в журнал.
  3. Новые тесты запускателя: on_sigterm (пересылка дочернему, без нового запуска); trial при перезапуске run.py (sha совпадает — пробный период продолжается; не совпадает — rolled_back); строки таблицы решения 1; решение 3 (ok в последний момент); решение 4 (OSError до и после замены, через подмену os.replace/open); решение 6 (код 73 в trial — без отката). Агент: решения 8–12; будущее время журнала + остановка в паузе (M-3 #18: claimed есть, started нет).

Выкатка

  1. Порядок: merge PR jira-hub (спека) и jira-agent → jira-hub agent publish src/agent.py (v9) из основного checkout агента → agent_status показывает «обновление: ok v8 → v9» → владелец останавливает run.py (launchctl unload), копирует run.py v2 в ~/jira-agent-bundle, запускает (launchctl load) → agent_status показывает «запускатель: v2». Запускатель v1 с агентом v9 работает (код 73 для него — обычное падение, как раньше с 1); запускатель v2 с агентом v8 — тоже (v8 выходит с 1).

Risks / Trade-offs

  • [Устаревший trial → rolled_back] ручная подмена agent.py блокирует автоустановку той же версии → публикация с номером выше.
  • [rejected после OSError в подмене] версия не ставится, пока не опубликована следующая → видно в agent_status («v отвергнута: подмена не удалась…»).
  • [sha256 на каждом запуске] при повреждённом update.json (нет sha256) пробного периода нет → строка «иное» таблицы, агент работает как подтверждённый; это поведение v1 при неизвестном состоянии.
  • [Код 73 от осиротевшего агента в trial] новая версия не стартует, пока жив чужой процесс; пробный период каждый раз заново → в логе запускателя видно «журнал занят».

Decisions made without discussion

Маркер Дата Решение Обоснование Цена ошибки
R-1 (design #6, #8) 2026-10-14 Код «журнал занят» — 73 (EX_CANTCREAT) рядом с 75 (EX_TEMPFAIL) из sysexits; не пересекается с 0/1/2/3/130 смена кода — правка двух файлов и спеки
R-2 (design #12) 2026-10-14 Подлежащее «хаб: » добавляется при обёртке в refresh, а не в тексте FolderTransport check уже печатает «хаб: НЕДОСТУПЕН — <текст>»; «хаб» в тексте транспорта дал бы «хаб: НЕДОСТУПЕН — хаб не ответил» нет
R-3 (design #10) 2026-10-14 Неверный формат agent_release — молча игнорировать с логом раз в час, без rejected версия неизвестна — записать rejected не для чего; хаб такого не отдаёт лишний час до заметной строки в логе
R-4 (design #3) 2026-10-14 ok в последний момент — перезапуск без паузы агента остановил запускатель, а не сбой нет
R-5 (design #15, #16) 2026-10-14 Фейки: только project, created и долгий claim; 400 на key in, agile без fields, PUT null уже как в Jira проверено по коду фейков нет