## 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 — от байтов файла; - запись в `/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`. ### Агент 7. **Состояние обновления — `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`. 8. **Когда проверять.** Только в `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 минут назад. 9. **Подготовка версии.** 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 = ` 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` удаляется, обычный выход. 10. **`selftest`.** `python3 agent.py selftest`: печатает «jira-agent v», выполняет то же, что `check`; код 0 — хаб и Jira доступны; 3 — что-то недоступно. Блокировку журнала не берёт, в Jira не пишет. Контракт кодов (0/3) MUST сохраняться в следующих версиях: его вызывает предыдущая версия. 11. **Первый успешный `claim`** новой версии: если `update.json` = `trial` и `to_version = AGENT_VERSION` — записать `ok`, лог «обновление до v подтверждено». 12. **`auto_update`** — необязательное поле `agent.json`, по умолчанию `true`; `false` — агент не проверяет публикацию и не пишет подсказку. 13. **Версия 5.** `AGENT_VERSION = 5`; `min_agent_version` хаба не поднимается. ### Запускатель `run.py` 14. **Запуск.** `python3 run.py [аргументы]` из любого каталога: работает с `agent.py`, `update.json` и т. д. в каталоге самого `run.py`; аргументы передаются `agent.py run` как есть (`--config`, `--journal`). Константа `LAUNCHER_VERSION = 1`. Своя блокировка `run.py.lock` (`flock`, неблокирующе): занята — «запускатель уже работает», код 1. 15. **Дочерний процесс.** `[sys.executable, "-u", <каталог>/agent.py, "run", *аргументы]` с переменной окружения `JIRA_AGENT_LAUNCHER=`; stdout/stderr наследуются (лог агента и запускателя — в одном месте). Агент передаёт `launcher_version` в `claim`. Строки запускателя — с префиксом «run:» и временем, как у агента. 16. **Сигналы.** SIGINT при живом агенте запускатель только запоминает как «остановил человек» (Ctrl-C в терминале и так приходит дочернему процессу — общая группа); без агента (пауза перед перезапуском) — выход. После сигнала остановки новый агент не запускается. SIGTERM (`kill`, `launchctl unload`) пересылается дочернему процессу; повторный SIGTERM — пересылается снова (агент выходит немедленно, 130). После выхода дочернего процесса по сигналу запускатель выходит. 17. **Коды выхода дочернего процесса:** - `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). 18. **Пробный период.** Пока `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` пробный период начинается заново. 19. **Перезапуск после падения.** Пауза 5 с, удваивается до 300 с при каждом следующем падении; если процесс проработал не меньше 10 минут, пауза сбрасывается до 5 с. Лог «run: агент упал (код ), перезапуск через с». Ctrl-C или SIGTERM во время паузы — выход. 20. **Старт запускателя.** `update.json` = `staged` и есть `agent.py.new` — подмена, как по коду 75 (запускатель остановили между выходом агента и подменой). Лишний `agent.py.new` без `staged` — удаляется. `agent.py` отсутствует, а `agent.py.prev` есть — восстановить из `.prev` (защита от ручной ошибки; штатная подмена `agent.py` не удаляет). ### Выкатка и процесс 21. **Совместимость.** Старый хаб: нет `agent_release` в ответе — агент v5 не обновляется; лишние поля в теле `claim` старый хаб игнорирует (`body.get`). Агент v4 с новым хабом поле `agent_release` игнорирует. 22. **Последнее ручное обновление.** После 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`. 23. **Публикация в процессе.** В чеклист закрытия PR агента (CLAUDE.md агента) добавляется пункт: после merge `uv run --project jira-hub agent publish /src/agent.py` и проверка `agent_status` через несколько минут (`update_state = ok`, версия новая). 24. **Живая проверка** (сценарий в `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 ` теперь перезапускает агента — останавливать нужно `run.py` (README) | | R-21 (финальное ревью) | 2026-10-14 | Находка M-1 ревью агента (отрицательный `sleep` в `pause` роняет запускатель) повышена до Important и исправлена | `run.py` обновляется вручную, падение запускателя останавливает мост до человека | нет |