Andrey Epifancev ca231ba2ae docs(openspec): архив agent-self-update (#22)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 22:58:46 +04:00

27 KiB
Raw Blame History

Context

Мост в работе: хаб на Mac mini (launchd, схема v2), агент v4 на виртуалке банка через общую папку UTM (hub_url = folder:/Volumes/My Shared Files/RSHB/jira-bridge), запущен владельцем в терминале из ~/jira-agent-bundle. Код: хаб main 3931a88, агент main 96c92d9. Параллельно идёт web-app (#21, ветка feat/21-web-app): из бэкенда она трогает только api.py (раздача приложения) и требование «Процесс и доступ» спеки hub-api.

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

  • agent.py: HERE = dirname(abspath(__file__)) — agent.json и journal.jsonl ищутся рядом с запущенным файлом, значит python3 agent.py.new selftest в том же каталоге читает тот же agent.json.
  • acquire_lock открывает <журнал>.lock через open() — fd не наследуется дочерними процессами (PEP 446); блокировка flock освобождается при выходе процесса.
  • Runner.loop: после claim без команды — idle += 1, sleep(poll). Это единственная точка, где у агента нет команды в полёте и нет неподтверждённого отчёта.
  • cmd_check делает одно чтение GET /myself в Jira и GET /agent/settings в хаб.
  • Хаб: h_claim отдаёт {command, settings_version}; GET /agent — queue.agent_view(conn); MCP agent_status форматирует его fmt_agent. Тело запроса ограничено 16 МБ; ответы реле — строка JSON в файле.
  • Python, упавший на необработанном исключении или SyntaxError, выходит с кодом 1; argparse при ошибке — 2.

Goals / Non-Goals

Goals: после одного последнего ручного обновления агент обновляется сам после jira-hub agent publish; плохая версия не останавливает мост (не ставится или откатывается); правило «одна команда в полёте», журнал и блокировка не нарушаются; хаб без миграции схемы; совместимость в обе стороны со старыми хабом и агентом.

Non-Goals: см. proposal.

Decisions

Хаб

  1. Публикация. jira-hub agent publish PATH:
    • читает файл как UTF-8 (иначе ошибка), размер не больше 1 МБ;
    • версия — из единственной строки вида AGENT_VERSION = <целое> (регулярное выражение по строкам; нет или несколько — ошибка);
    • версия MUST быть больше опубликованной (если есть), иначе ошибка «опубликована v, новая должна быть больше»;
    • sha256 — от байтов файла;
    • запись в <JIRA_HUB_HOME>/release/ (каталог 0700): agent.py через временный файл и os.replace, затем manifest.json {version, sha256, size, published_at} так же; манифест пишется последним — по нему хаб считает версию опубликованной;
    • вывод: «опубликована v, sha256 <первые 12>…, байт»; код 0. Ошибки — сообщение и код 1. Работает без запущенного serve: сервер читает манифест при каждом обращении.
  2. Чтение публикации (release.py): current(home) -> {version, sha256} | None — манифест; битый или неполный манифест — None и предупреждение в лог (агенту нечего обновлять, это безопасно). load(home) -> {version, sha256, content} — манифест и файл; sha256 файла пересчитывается, несовпадение — None и ошибка в лог.
  3. claim. Ответ дополняется agent_release: current(home) всегда, независимо от версии агента — решает агент. Тело claim принимает необязательные update_state (объект или null) и launcher_version (целое или null); хаб сохраняет последние значения в памяти процесса (Hub.agent_update) при каждом claim; значения неверного типа игнорируются (claim не отвергается). В update_state.error хаб хранит не больше 500 символов.
  4. GET /agent/release (агентский токен): 200 {version, sha256, content}; нет публикации или файл не сходится с манифестом — 404 {error: "no_release"}.
  5. GET /agent дополняется полями release (current + published_at), update_state, launcher_version (последние из памяти; после перезапуска хаба — null до первого claim) и update_hint:
    • release.version > agent_version, агент онлайн и launcher_version = null → «агент запущен без run.py — обновится только вручную»;
    • update_state.state = "rolled_back" → «откат с v на v: »;
    • update_state.state = "rejected" → «v отвергнута: »;
    • иначе null.
  6. MCP agent_status выводит дополнительно строку «публикация: v от <дата>» (или «публикации нет»), строку «обновление: v → v» при наличии update_state, «запускатель: v» / «без запускателя» и update_hint, если не null.

Агент

  1. Состояние обновления — update.json рядом с agent.py: {state, from_version, to_version, sha256, error, at}. Пишется атомарно (временный файл + os.replace). Состояния:
    • staged — агент проверил agent.py.new и выходит с кодом 75 (пишет агент);
    • trial — запускатель подменил файл и запустил новую версию (пишет запускатель);
    • ok — новая версия получила первый успешный ответ claim (пишет агент, только из состояния trial со своей версией = to_version);
    • rolled_back — запускатель вернул прежнюю версию (пишет запускатель, error — «код через <сек> с» или «не вышла на связь за 10 мин»);
    • rejected — агент отверг версию до подмены (error — причина). Нет файла — null. Агент передаёт содержимое в каждом claim как update_state.
  2. Когда проверять. Только в Runner.loop после ответа claim с command = null, если stop не выставлен, auto_update не false и agent_release.version > AGENT_VERSION. Пропуск:
    • update.json в rejected/rolled_back с тем же to_version и sha256 — версия уже отвергнута;
    • запуск без запускателя (нет переменной окружения JIRA_AGENT_LAUNCHER) — строка в лог «доступна v; для самообновления запускайте через run.py», не чаще раза в час;
    • предыдущая попытка этой версии закончилась временным сбоем меньше 5 минут назад.
  3. Подготовка версии.
    1. GET /agent/release; ConnectionError/HttpError — временный сбой.
    2. version/sha256 ответа MUST совпасть с agent_release из claim (иначе временный сбой — публикация сменилась между запросами), sha256 от content.encode("utf-8") MUST совпасть с sha256 (иначе rejected «sha256 не сходится»).
    3. compile(content, "agent.py.new", "exec") — SyntaxError → rejected; строка AGENT_VERSION = <N> MUST дать N = version → иначе rejected.
    4. Запись agent.py.new (временный файл + os.replace, права как у agent.py).
    5. python3 agent.py.new selftest (sys.executable, тот же каталог, таймаут 120 с): код 0 — годен; 3 — временный сбой (хаб или Jira недоступны) или таймаут; любой другой — rejected «selftest: код » с последними 500 символами вывода.
    6. Годен: update.json = staged, лог «v готова, перезапуск через run.py», выход из цикла и run с кодом 75. При rejected и временном сбое agent.py.new удаляется; при rejected пишется update.json; агент продолжает работу. Сигнал остановки во время подготовки — agent.py.new удаляется, обычный выход.
  4. selftest. python3 agent.py selftest: печатает «jira-agent v», выполняет то же, что check; код 0 — хаб и Jira доступны; 3 — что-то недоступно. Блокировку журнала не берёт, в Jira не пишет. Контракт кодов (0/3) MUST сохраняться в следующих версиях: его вызывает предыдущая версия.
  5. Первый успешный claim новой версии: если update.json = trial и to_version = AGENT_VERSION — записать ok, лог «обновление до v подтверждено».
  6. auto_update — необязательное поле agent.json, по умолчанию true; false — агент не проверяет публикацию и не пишет подсказку.
  7. Версия 5. AGENT_VERSION = 5; min_agent_version хаба не поднимается.

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

  1. Запуск. python3 run.py [аргументы] из любого каталога: работает с agent.py, update.json и т. д. в каталоге самого run.py; аргументы передаются agent.py run как есть (--config, --journal). Константа LAUNCHER_VERSION = 1. Своя блокировка run.py.lock (flock, неблокирующе): занята — «запускатель уже работает», код 1.
  2. Дочерний процесс. [sys.executable, "-u", <каталог>/agent.py, "run", *аргументы] с переменной окружения JIRA_AGENT_LAUNCHER=<LAUNCHER_VERSION>; stdout/stderr наследуются (лог агента и запускателя — в одном месте). Агент передаёт launcher_version в claim. Строки запускателя — с префиксом «run:» и временем, как у агента.
  3. Сигналы. SIGINT при живом агенте запускатель только запоминает как «остановил человек» (Ctrl-C в терминале и так приходит дочернему процессу — общая группа); без агента (пауза перед перезапуском) — выход. После сигнала остановки новый агент не запускается. SIGTERM (kill, launchctl unload) пересылается дочернему процессу; повторный SIGTERM — пересылается снова (агент выходит немедленно, 130). После выхода дочернего процесса по сигналу запускатель выходит.
  4. Коды выхода дочернего процесса:
    • 0, 130 или -SIGINT после Ctrl-C (или SIGTERM запускателю) — остановка человеком: запускатель выходит с тем же кодом; те же коды без сигнала запускателю — падение (R-20);
    • 75 — обновление: если update.json = staged и sha256 agent.py.new совпадает — копия agent.py в agent.py.prev (временный файл + os.replace), затем os.replace(agent.py.new, agent.py) (файл agent.py существует всегда), update.json = trial {from_version, to_version, sha256, at}, запуск новой версии. Нет staged или sha не сходится — agent.py.new удаляется, update.json = rejected «файл изменился после проверки», запуск текущей версии;
    • иное — падение: в пробный период — откат (п. 18), иначе — перезапуск той же версии с паузой (п. 19).
  5. Пробный период. Пока update.json = trial, запускатель раз в секунду читает его. Новая версия здорова, когда агент записал ok. Откат, если дочерний процесс упал (код не 0/130/75) или за 10 минут не стал здоровым (тогда SIGTERM, через 60 с SIGKILL): agent.py.prev → agent.py (os.replace), update.json = rolled_back, лог «run: откат v → v: <причина>», запуск прежней версии без паузы. Остановка человеком в пробный период (0/130) — выход; при следующем старте run.py с trial пробный период начинается заново.
  6. Перезапуск после падения. Пауза 5 с, удваивается до 300 с при каждом следующем падении; если процесс проработал не меньше 10 минут, пауза сбрасывается до 5 с. Лог «run: агент упал (код ), перезапуск через с». Ctrl-C или SIGTERM во время паузы — выход.
  7. Старт запускателя. update.json = staged и есть agent.py.new — подмена, как по коду 75 (запускатель остановили между выходом агента и подменой). Лишний agent.py.new без staged — удаляется. agent.py отсутствует, а agent.py.prev есть — восстановить из .prev (защита от ручной ошибки; штатная подмена agent.py не удаляет).

Выкатка и процесс

  1. Совместимость. Старый хаб: нет agent_release в ответе — агент v5 не обновляется; лишние поля в теле claim старый хаб игнорирует (body.get). Агент v4 с новым хабом поле agent_release игнорирует.
  2. Последнее ручное обновление. После merge хаба (pull + kickstart) — на виртуалке остановить v4, положить agent.py v5 и run.py в ~/jira-agent-bundle, python3 agent.py check, запуск python3 run.py. При launchd — в plist run.py вместо agent.py.
  3. Публикация в процессе. В чеклист закрытия PR агента (CLAUDE.md агента) добавляется пункт: после merge uv run --project <jira-hub> jira-hub agent publish <jira-agent>/src/agent.py и проверка agent_status через несколько минут (update_state = ok, версия новая).
  4. Живая проверка (сценарий в awp-ca/admin/): публикация v6 (только номер) — агент обновился, ok; v7, падающая на старте, — откат на v6, rolled_back, повторно не ставится; v8 = код v6 — ставится.

Risks / Trade-offs

  • Код на банковской виртуалке без человека. Кто владеет Mac mini (каталог данных хаба, токены) — ставит код на виртуалку. Сейчас то же самое: владелец копирует файл, лежащий на Mac mini. sha256 защищает от битой передачи, не от подмены. Подпись не делается (R-9).
  • Версия, проходящая selftest и первый claim, но ломающаяся позже (например, на выполнении команды). Запускатель перезапустит её с паузой, откатом это не считается; видно в логе и по agent_status (команды failed). Лечится публикацией исправления — агент на связи и обновится.
  • Ошибка в run.py не лечится автоматически: файл маленький, меняется вручную, покрыт тестами на поддельных агентах.
  • Пересечение с #21 — только api.py; ветка, сливаемая второй, ребейзится.

Migration Plan

  1. Merge хаба → git pull в jira-hub, launchctl kickstart -k gui/$(id -u)/ru.aepif.jira-hub; GET /agent: release = null.
  2. Merge агента → файлы agent.py v5 и run.py в корень RSHB/ (ловушка VirtioFS: новую папку виртуалка может не увидеть); владелец на виртуалке: остановить v4, скопировать в ~/jira-agent-bundle, check, python3 run.py; agent_status: версия 5, запускатель v1. Удалить файлы из RSHB/.
  3. Живая проверка (п. 24). Откат всей фичи: запуск python3 agent.py напрямую (без запускателя агент себя не обновляет), публикации не делать.

Decisions made without discussion

Владелец 14.10.2026 утвердил: доставку через хаб (A), запускатель с откатом (2), код только в каталоге run.py на виртуалке, части 1–3 дизайна в чате. Остальное — рулинги при написании спеки.

Маркер Дата Решение Обоснование Цена ошибки
R-1 (design #1) 2026-10-14 Публикация только вверх; снятия публикации и --force нет агент двигается только вверх; откат — публикация старого кода с новым номером для отката нужно поднять номер в файле — одна строка
R-2 (design #1, #2) 2026-10-14 Публикация — файлы в <данные>/release/, без таблицы в БД без миграции схемы и без пересечения с #21; CLI работает без serve нет истории публикаций — она в git агента
R-3 (design #3) 2026-10-14 update_state и launcher_version хаб держит в памяти, агент шлёт их в каждом claim без миграции; после перезапуска хаба восстанавливаются за секунды между перезапуском хаба и первым claim состояние обновления не видно
R-4 (design #8) 2026-10-14 Проверка обновления только после claim без команды единственная точка без команды в полёте и неподтверждённого отчёта при непрерывной очереди обновление ждёт её опустошения
R-5 (design #18) 2026-10-14 Здоровье новой версии — первый успешный claim; пробный период 10 мин; SIGTERM, через 60 с SIGKILL claim без команды отвечает за секунды и проверяет связь с хабом, настройки и цикл версия, ломающаяся только на командах, откатом не ловится (см. Risks)
R-6 (design #19) 2026-10-14 Перезапуск после падения — пауза 5…300 с, сброс после 10 мин работы не долбить хаб при постоянном падении, быстро подниматься при разовом до 5 мин простоя при повторяющемся падении
R-7 (design #9, #10) 2026-10-14 Компиляция и версия проверяются в старом процессе; selftest — коды 0/3, прочие — отказ код 1 у Python — любое исключение, нельзя отличить «нет сети» от «битый файл» новая версия, сломавшая контракт selftest, будет отвергнута — видно в agent_status
R-8 (design #8) 2026-10-14 Временный сбой подготовки — повтор не чаще раза в 5 мин папка или Jira могут быть недоступны минутами обновление задерживается до 5 мин после восстановления связи
R-9 (Risks) 2026-10-14 Подписи файла нет, только sha256 доверие то же, что при ручном копировании с Mac mini; YAGNI при компрометации Mac mini код на виртуалку ставится без человека
R-10 (design #8) 2026-10-14 Без запускателя агент себя не обновляет, только пишет подсказку раз в час выход с кодом 75 без запускателя остановил бы мост нет
R-11 (design #12) 2026-10-14 auto_update в agent.json, по умолчанию true выключатель на стороне виртуалки, без публикации нет
R-12 (design #14, #15) 2026-10-14 run.py с LAUNCHER_VERSION = 1, своя блокировка, версия передаётся в claim видно, где запускатель устарел; два запускателя не стартуют нет
R-13 (design #17) 2026-10-14 Код выхода 75 («обновление готово»), проверка sha agent.py.new ещё раз в запускателе 75 = EX_TEMPFAIL, не пересекается с 0/1/2/130; повторная проверка ловит подмену файла между процессами нет
R-14 (design #3) 2026-10-14 agent_release в каждом ответе claim, решение — за агентом хаб не знает, отвергнута ли версия; меньше логики в хабе лишние ~80 байт в ответе
R-15 (Context) 2026-10-14 Хаб правится параллельно с #21; дельта hub-api трогает только «Маршруты агента» и добавляет новое требование у web-app — «Процесс и доступ» и раздача приложения; архивы не конфликтуют конфликт в api.py при ребейзе — пара строк
R-16 (design #15) 2026-10-14 stdout/stderr дочернего процесса наследуются, в rolled_back — только код и время трейсбек остаётся в логе агента; захват вывода усложняет запускатель причину отката видно в логе на виртуалке, в хабе — только код
R-17 (design #13) 2026-10-14 Агент — версия 5, min_agent_version не поднимается v4 продолжает работать до ручного обновления нет
R-18 (design #1) 2026-10-14 Публикация через CLI хаба, не через MCP публикация ставит код на банковскую виртуалку — только явной командой в терминале нет
R-19 (агент, исполнение) 2026-10-14 tests/test_folder.py::test_version_is_4 переименован в test_version_is_5 (AGENT_VERSION = 5) план не учёл старый тест версии; спека требует v5 нет
R-20 (design #16, #17) 2026-10-14 Выход агента с 0/130 считается остановкой человеком, только если запускатель сам получил SIGINT/SIGTERM; иначе — падение (в trial — откат, вне — перезапуск с паузой); Ctrl-C при выходе с 75 — подмена и выход, trial при следующем старте; агент, получивший Ctrl-C во время подготовки, выходит с 0 финальное ревью агента I-1: версия, сама вышедшая с 0, останавливала мост без отката (под launchd — вечный цикл trial); Ctrl-C в терминале запускатель получает всегда (общая группа) kill <pid агента> теперь перезапускает агента — останавливать нужно run.py (README)
R-21 (финальное ревью) 2026-10-14 Находка M-1 ревью агента (отрицательный sleep в pause роняет запускатель) повышена до Important и исправлена run.py обновляется вручную, падение запускателя останавливает мост до человека нет