195 lines
25 KiB
Markdown
195 lines
25 KiB
Markdown
## 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 | публикация ставит код на банковскую виртуалку — только явной командой в терминале | нет |
|