Andrey Epifancev ce148b69f9 docs(openspec): agent-self-update — proposal, design, дельты agent/hub-api/mcp (#22)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 21:49:21 +04:00

195 lines
25 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

## 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<N>, новая должна быть больше»;
- sha256 — от байтов файла;
- запись в `<JIRA_HUB_HOME>/release/` (каталог 0700): `agent.py` через временный файл и `os.replace`, затем
`manifest.json` `{version, sha256, size, published_at}` так же; манифест пишется последним — по нему хаб считает
версию опубликованной;
- вывод: «опубликована v<N>, sha256 <первые 12>…, <size> байт»; код 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<to> на v<from>: <error>»;
- `update_state.state = "rejected"` → «v<to> отвергнута: <error>»;
- иначе `null`.
6. **MCP `agent_status`** выводит дополнительно строку «публикация: v<N> от <дата>» (или «публикации нет»), строку
«обновление: <state> v<from> → v<to>» при наличии `update_state`, «запускатель: v<N>» / «без запускателя» и
`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` — «код <N> через <сек> с» или
«не вышла на связь за 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<N>; для
самообновления запускайте через 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 = <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: код <N>» с последними 500
символами вывода.
6. Годен: `update.json` = `staged`, лог «v<N> готова, перезапуск через run.py», выход из цикла и `run` с кодом 75.
При `rejected` и временном сбое `agent.py.new` удаляется; при `rejected` пишется `update.json`; агент продолжает
работу. Сигнал остановки во время подготовки — `agent.py.new` удаляется, обычный выход.
10. **`selftest`.** `python3 agent.py selftest`: печатает «jira-agent v<N>», выполняет то же, что `check`; код 0 —
хаб и Jira доступны; 3 — что-то недоступно. Блокировку журнала не берёт, в Jira не пишет. Контракт кодов (0/3)
MUST сохраняться в следующих версиях: его вызывает предыдущая версия.
11. **Первый успешный `claim`** новой версии: если `update.json` = `trial` и `to_version = AGENT_VERSION` —
записать `ok`, лог «обновление до v<N> подтверждено».
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=<LAUNCHER_VERSION>`; stdout/stderr наследуются (лог агента и запускателя — в одном месте).
Агент передаёт `launcher_version` в `claim`. Строки запускателя — с префиксом «run:» и временем, как у агента.
16. **Сигналы.** SIGINT запускатель игнорирует (Ctrl-C в терминале и так приходит дочернему процессу — общая группа).
SIGTERM (`kill`, `launchctl unload`) пересылается дочернему процессу; повторный SIGTERM — пересылается снова
(агент выходит немедленно, 130). После выхода дочернего процесса по сигналу запускатель выходит.
17. **Коды выхода дочернего процесса:**
- `0` или `130` — остановка человеком: запускатель выходит с тем же кодом;
- `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<to> → v<from>: <причина>», запуск прежней версии без паузы. Остановка человеком в пробный период
(0/130) — выход; при следующем старте `run.py` с `trial` пробный период начинается заново.
19. **Перезапуск после падения.** Пауза 5 с, удваивается до 300 с при каждом следующем падении; если процесс проработал
не меньше 10 минут, пауза сбрасывается до 5 с. Лог «run: агент упал (код <N>), перезапуск через <S> с». 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> jira-hub agent publish <jira-agent>/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 | публикация ставит код на банковскую виртуалку — только явной командой в терминале | нет |