27 KiB
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); MCPagent_statusформатирует егоfmt_agent. Тело запроса ограничено 16 МБ; ответы реле — строка JSON в файле. - Python, упавший на необработанном исключении или
SyntaxError, выходит с кодом 1;argparseпри ошибке — 2.
Goals / Non-Goals
Goals: после одного последнего ручного обновления агент обновляется сам после jira-hub agent publish; плохая
версия не останавливает мост (не ставится или откатывается); правило «одна команда в полёте», журнал и блокировка не
нарушаются; хаб без миграции схемы; совместимость в обе стороны со старыми хабом и агентом.
Non-Goals: см. proposal.
Decisions
Хаб
- Публикация.
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: сервер читает манифест при каждом обращении.
- Чтение публикации (
release.py):current(home) -> {version, sha256} | None— манифест; битый или неполный манифест —Noneи предупреждение в лог (агенту нечего обновлять, это безопасно).load(home) -> {version, sha256, content}— манифест и файл; sha256 файла пересчитывается, несовпадение —Noneи ошибка в лог. claim. Ответ дополняетсяagent_release: current(home)всегда, независимо от версии агента — решает агент. Телоclaimпринимает необязательныеupdate_state(объект илиnull) иlauncher_version(целое илиnull); хаб сохраняет последние значения в памяти процесса (Hub.agent_update) при каждомclaim; значения неверного типа игнорируются (claim не отвергается). Вupdate_state.errorхаб хранит не больше 500 символов.GET /agent/release(агентский токен):200 {version, sha256, content}; нет публикации или файл не сходится с манифестом —404 {error: "no_release"}.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.
- MCP
agent_statusвыводит дополнительно строку «публикация: v от <дата>» (или «публикации нет»), строку «обновление: v → v» при наличииupdate_state, «запускатель: v» / «без запускателя» иupdate_hint, если неnull.
Агент
- Состояние обновления —
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.
- Когда проверять. Только в
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 минут назад.
- Подготовка версии.
GET /agent/release;ConnectionError/HttpError— временный сбой.version/sha256ответа MUST совпасть сagent_releaseизclaim(иначе временный сбой — публикация сменилась между запросами), sha256 отcontent.encode("utf-8")MUST совпасть сsha256(иначеrejected«sha256 не сходится»).compile(content, "agent.py.new", "exec")—SyntaxError→rejected; строкаAGENT_VERSION = <N>MUST датьN = version→ иначеrejected.- Запись
agent.py.new(временный файл +os.replace, права как уagent.py). python3 agent.py.new selftest(sys.executable, тот же каталог, таймаут 120 с): код 0 — годен; 3 — временный сбой (хаб или Jira недоступны) или таймаут; любой другой —rejected«selftest: код » с последними 500 символами вывода.- Годен:
update.json=staged, лог «v готова, перезапуск через run.py», выход из цикла иrunс кодом 75. Приrejectedи временном сбоеagent.py.newудаляется; приrejectedпишетсяupdate.json; агент продолжает работу. Сигнал остановки во время подготовки —agent.py.newудаляется, обычный выход.
selftest.python3 agent.py selftest: печатает «jira-agent v», выполняет то же, чтоcheck; код 0 — хаб и Jira доступны; 3 — что-то недоступно. Блокировку журнала не берёт, в Jira не пишет. Контракт кодов (0/3) MUST сохраняться в следующих версиях: его вызывает предыдущая версия.- Первый успешный
claimновой версии: еслиupdate.json=trialиto_version = AGENT_VERSION— записатьok, лог «обновление до v подтверждено». auto_update— необязательное полеagent.json, по умолчаниюtrue;false— агент не проверяет публикацию и не пишет подсказку.- Версия 5.
AGENT_VERSION = 5;min_agent_versionхаба не поднимается.
Запускатель run.py
- Запуск.
python3 run.py [аргументы]из любого каталога: работает сagent.py,update.jsonи т. д. в каталоге самогоrun.py; аргументы передаютсяagent.py runкак есть (--config,--journal). КонстантаLAUNCHER_VERSION = 1. Своя блокировкаrun.py.lock(flock, неблокирующе): занята — «запускатель уже работает», код 1. - Дочерний процесс.
[sys.executable, "-u", <каталог>/agent.py, "run", *аргументы]с переменной окруженияJIRA_AGENT_LAUNCHER=<LAUNCHER_VERSION>; stdout/stderr наследуются (лог агента и запускателя — в одном месте). Агент передаётlauncher_versionвclaim. Строки запускателя — с префиксом «run:» и временем, как у агента. - Сигналы. SIGINT при живом агенте запускатель только запоминает как «остановил человек» (Ctrl-C в терминале и так
приходит дочернему процессу — общая группа); без агента (пауза перед перезапуском) — выход. После сигнала остановки
новый агент не запускается.
SIGTERM (
kill,launchctl unload) пересылается дочернему процессу; повторный SIGTERM — пересылается снова (агент выходит немедленно, 130). После выхода дочернего процесса по сигналу запускатель выходит. - Коды выхода дочернего процесса:
0,130или-SIGINTпосле Ctrl-C (или SIGTERM запускателю) — остановка человеком: запускатель выходит с тем же кодом; те же коды без сигнала запускателю — падение (R-20);75— обновление: еслиupdate.json=stagedи sha256agent.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).
- Пробный период. Пока
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пробный период начинается заново. - Перезапуск после падения. Пауза 5 с, удваивается до 300 с при каждом следующем падении; если процесс проработал
не меньше 10 минут, пауза сбрасывается до 5 с. Лог «run: агент упал (код ), перезапуск через
с». Ctrl-C или SIGTERM во время паузы — выход. - Старт запускателя.
update.json=stagedи естьagent.py.new— подмена, как по коду 75 (запускатель остановили между выходом агента и подменой). Лишнийagent.py.newбезstaged— удаляется.agent.pyотсутствует, аagent.py.prevесть — восстановить из.prev(защита от ручной ошибки; штатная подменаagent.pyне удаляет).
Выкатка и процесс
- Совместимость. Старый хаб: нет
agent_releaseв ответе — агент v5 не обновляется; лишние поля в телеclaimстарый хаб игнорирует (body.get). Агент v4 с новым хабом полеagent_releaseигнорирует. - Последнее ручное обновление. После merge хаба (pull + kickstart) — на виртуалке остановить v4, положить
agent.pyv5 иrun.pyв~/jira-agent-bundle,python3 agent.py check, запускpython3 run.py. При launchd — в plistrun.pyвместоagent.py. - Публикация в процессе. В чеклист закрытия PR агента (CLAUDE.md агента) добавляется пункт: после merge
uv run --project <jira-hub> jira-hub agent publish <jira-agent>/src/agent.pyи проверкаagent_statusчерез несколько минут (update_state = ok, версия новая). - Живая проверка (сценарий в
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
- Merge хаба →
git pullвjira-hub,launchctl kickstart -k gui/$(id -u)/ru.aepif.jira-hub;GET /agent:release = null. - Merge агента → файлы
agent.pyv5 иrun.pyв кореньRSHB/(ловушка VirtioFS: новую папку виртуалка может не увидеть); владелец на виртуалке: остановить v4, скопировать в~/jira-agent-bundle,check,python3 run.py;agent_status: версия 5, запускатель v1. Удалить файлы изRSHB/. - Живая проверка (п. 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 обновляется вручную, падение запускателя останавливает мост до человека |
нет |