diff --git a/openspec/changes/agent-self-update/.openspec.yaml b/openspec/changes/agent-self-update/.openspec.yaml new file mode 100644 index 0000000..49ea85b --- /dev/null +++ b/openspec/changes/agent-self-update/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-14 diff --git a/openspec/changes/agent-self-update/design.md b/openspec/changes/agent-self-update/design.md new file mode 100644 index 0000000..bfd88b6 --- /dev/null +++ b/openspec/changes/agent-self-update/design.md @@ -0,0 +1,194 @@ +## 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` — остановка человеком: запускатель выходит с тем же кодом; + - `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 | публикация ставит код на банковскую виртуалку — только явной командой в терминале | нет | diff --git a/openspec/changes/agent-self-update/proposal.md b/openspec/changes/agent-self-update/proposal.md new file mode 100644 index 0000000..62aba59 --- /dev/null +++ b/openspec/changes/agent-self-update/proposal.md @@ -0,0 +1,65 @@ +## Why + +Мост в ежедневной работе с 13.10.2026, агент меняется часто: v2 → v3 → v4 за два дня. Каждое обновление — ручная +процедура на виртуалке банка: положить `agent.py` в корень общей папки, остановить старый процесс, скопировать файл +в `~/jira-agent-bundle`, `check`, `run`. Хаб уже знает версию агента (`agent_version` в `claim`, предупреждение по +`min_agent_version`), но доставлять код не умеет; падение агента никто не поднимает — он запущен в терминале. + +Решения владельца 14.10.2026 (brainstorming): +- доставка через хаб (вариант A): явная публикация `jira-hub agent publish`, версия в ответе `claim`, файл — по + `GET /agent/release`; работает и по HTTP, и через общую папку; +- перезапуск и откат — маленький постоянный запускатель `run.py` рядом с `agent.py` (вариант 2): подмена файла, + пробный период, откат на прежнюю версию, перезапуск после падения; +- код в общую папку не кладётся: агент получает файл через API хаба, пишет и запускает его в каталоге `run.py` на + виртуалке (`~/jira-agent-bundle`); +- хаб правится параллельно с `web-app` (#21), без пересечения требований спек; +- после этого change ручное обновление на виртуалке — одно последнее (v5 + `run.py`); дальше агент обновляется сам + после публикации, которую делает Claude в чеклисте закрытия PR агента. + +## What Changes + +- **jira-hub**: + - команда `jira-hub agent publish `: версия из строки `AGENT_VERSION = N`, sha256, только вверх; файл и + `manifest.json` в `<данные>/release/` (права 0700); схема БД не меняется; + - ответ `POST /agent/claim` — поле `agent_release: {version, sha256} | null`; + - `POST /agent/claim` принимает `update_state` и `launcher_version`; хаб держит последние значения в памяти; + - маршрут `GET /agent/release` (агентский токен) → `{version, sha256, content}`; 404 `no_release`; + - `GET /agent` и MCP `agent_status`: опубликованная версия, состояние обновления, версия запускателя, подсказка + «агент запущен без run.py». +- **jira-agent** (версия 5): + - новый файл `run.py` (Python 3.9, stdlib): дочерний `agent.py run`, код 75 — подмена файла, пробный период до первого + успешного `claim` (10 мин), откат, перезапуск после падения с паузой 5…300 с, своя блокировка `run.py.lock`; + - `agent.py`: в паузе между командами (ответ `claim` без команды) видит `agent_release` новее себя, скачивает, + проверяет sha256, компиляцию и версию в файле, запускает `agent.py.new selftest`, выходит с кодом 75; отвергнутую + версию не повторяет; без запускателя только пишет в лог раз в час; выключатель `auto_update` в `agent.json`; + - новая команда `agent.py selftest`; `update.json` — состояние обновления рядом с `agent.py`; + - README: запуск через `run.py`, последнее ручное обновление, публикация. + +## Capabilities + +### New Capabilities + + +### Modified Capabilities +- `agent`: состав (запускатель, `auto_update`, `selftest`), доставка и запуск; новые требования — самообновление и + запускатель. +- `hub-api`: маршруты агента (`agent_release`, `update_state`, `GET /agent/release`); новое требование — публикация + версии агента. +- `mcp`: `agent_status` показывает публикацию и состояние обновления. + +## Non-Goals + +- подпись файла ключом (целостность — sha256; доверие — как сейчас, см. design R-9); +- снятие публикации и откат «вниз» по команде — откат делается публикацией старого кода с новой версией; +- самообновление `run.py` — обновляется вручную, меняется редко; +- изменение схемы БД хаба и `min_agent_version` (не поднимается); +- показ публикации в веб-приложении (#21) — после его merge, отдельно. + +## Impact + +- jira-hub: `release.py` (новый), `cli.py`, `api.py` (`h_claim`, маршрут, `h_agent_get`), `queue.agent_view` не меняется, + `mcp_server.fmt_agent`, README, ARCHITECTURE.md, CLAUDE.md. Пересечение с веткой `feat/21-web-app` — только `api.py` + (разные места); дельта `hub-api` web-app трогает «Процесс и доступ», эта — «Маршруты агента». +- jira-agent: `src/agent.py` (v5), `src/run.py` (новый), тесты, README, ARCHITECTURE.md, CLAUDE.md. +- Выкатка: хаб — pull + kickstart; агент — последнее ручное обновление (v5 + `run.py`), затем живая проверка + публикацией v6, сломанной v7 (откат) и v8. diff --git a/openspec/changes/agent-self-update/specs/agent/spec.md b/openspec/changes/agent-self-update/specs/agent/spec.md new file mode 100644 index 0000000..016327d --- /dev/null +++ b/openspec/changes/agent-self-update/specs/agent/spec.md @@ -0,0 +1,133 @@ +## MODIFIED Requirements + +### Requirement: Состав и настройки агента +Агент SHALL состоять из файла `agent.py` (Python ≥ 3.9, только стандартная библиотека), запускателя `run.py` (те же +ограничения) и файла `agent.json`: `hub_url`, `hub_token`, `jira.base_url`, `jira.auth` (`basic` с `user`/`password` +или `bearer` с `token`), `verify_ssl`, `ca_bundle`, `timeout`, `poll_seconds`, необязательный `auto_update` (по +умолчанию `true`). `hub_url` вида `http://…` или `https://…` SHALL означать HTTP-транспорт, вида +`folder:<абсолютный путь>` — транспорт через общую папку. Всё остальное (id полей, типы, карта переходов, типы связей, +доска, темп) агент SHALL получать от хаба при старте и при изменении `settings_version`. Учётные данные Jira MUST NOT +отправляться в хаб и MUST NOT попадать в журнал, логи и общую папку. Команды `agent.py`: `run` (по умолчанию), `check`, +`selftest`, `prune`. + +#### Scenario: Проверка без записи +- **WHEN** `python3 agent.py check` +- **THEN** агент печатает доступность хаба (версия настроек) и Jira (`GET /myself`), в Jira ничего не пишет + +#### Scenario: Проверка через папку +- **WHEN** `hub_url = "folder:/Volumes/My Shared Files/RSHB/jira-bridge"` и реле хаба работает +- **THEN** `check` печатает «хаб: доступен через папку /Volumes/My Shared Files/RSHB/jira-bridge, настройки v, проект + »; если каталога нет или реле не ответило за 10 с — строка с «НЕДОСТУПЕН» и причиной, код выхода 1 + +#### Scenario: Самопроверка новой версии +- **WHEN** `python3 agent.py selftest`, хаб и Jira доступны +- **THEN** печатается «jira-agent v» и строки `check`; код 0; блокировка журнала не берётся, в Jira ничего не пишется; + если хаб или Jira недоступны — код 3 + +### Requirement: Доставка и запуск +README агента SHALL описывать: первую установку (копирование `agent.py`, `run.py` и `agent.example.json` через общую +папку в локальный каталог виртуалки, заполнение `agent.json` с выбором транспорта в `hub_url`, `check`), запуск +`python3 run.py` в терминале и через `nohup` с логом, опциональный launchd-plist с `run.py`, самообновление и +выключатель `auto_update`, ручное обновление `run.py`, `prune` (только при остановленном агенте, иначе откажет). +Версия агента SHALL быть в константе `AGENT_VERSION` файла (5 — самообновление) и в каждом `claim` и отчёте; хаб +предупреждает в `agent_status`, если версия ниже минимальной из настроек. + +#### Scenario: Устаревший агент +- **WHEN** агент версии 1 выходит на связь, а в настройках `min_agent_version = 2` +- **THEN** `agent_status.warning` = «обновите агента», команды выдаются по-прежнему + +## ADDED Requirements + +### Requirement: Самообновление агента +Агент SHALL передавать в каждом `claim` `update_state` (содержимое `update.json` рядом с `agent.py` или `null`) и +`launcher_version` (из переменной окружения `JIRA_AGENT_LAUNCHER` или `null`). Проверять обновление агент SHALL только +после ответа `claim` с `command = null`, без сигнала остановки, при `auto_update ≠ false` и +`agent_release.version > AGENT_VERSION`. Версию с теми же `to_version` и `sha256`, что в `update.json` в состоянии +`rejected` или `rolled_back`, агент MUST NOT ставить снова. Без `JIRA_AGENT_LAUNCHER` агент MUST NOT выходить для +обновления: он пишет в лог «доступна v; для самообновления запускайте через run.py» не чаще раза в час. + +Подготовка версии SHALL идти по шагам: `GET /agent/release`; совпадение `version`/`sha256` ответа с `agent_release` +(иначе временный сбой); sha256 от `content` в UTF-8 равен `sha256` (иначе `rejected`); `content` компилируется и +содержит `AGENT_VERSION = ` (иначе `rejected`); запись `agent.py.new` атомарно; `python3 agent.py.new selftest` +с таймаутом 120 с — код 0 годен, код 3 или таймаут — временный сбой, иной код — `rejected` с последними 500 символами +вывода. Годную версию агент SHALL отметить в `update.json` состоянием `staged` и завершить `run` с кодом 75. При +`rejected` и временном сбое `agent.py.new` SHALL удаляться, агент продолжает работу; при `rejected` пишется +`update.json`; повтор после временного сбоя — не раньше чем через 5 минут. Первый успешный `claim` версии, для которой +`update.json` = `trial` с `to_version = AGENT_VERSION`, SHALL записывать состояние `ok`. `update.json` пишется атомарно +(временный файл и переименование). + +#### Scenario: Обновление в паузе +- **WHEN** агент v5 под запускателем получил `claim` без команды и `agent_release = {version: 6, sha256: S}`, файл + проходит проверки и `selftest` +- **THEN** рядом лежит `agent.py.new`, `update.json` = `{state: staged, from_version: 5, to_version: 6, sha256: S}`, + процесс завершается с кодом 75; журнал не изменился + +#### Scenario: Команда в очереди +- **WHEN** опубликована v6, а `claim` агента v5 вернул команду +- **THEN** агент выполняет команду и отчитывается; к обновлению переходит только после `claim` без команды + +#### Scenario: Неверный sha256 +- **WHEN** `GET /agent/release` вернул `content`, sha256 которого не равен `sha256` +- **THEN** `agent.py.new` не остаётся, `update.json` = `rejected` «sha256 не сходится»; следующий `claim` несёт этот + `update_state`; агент v5 работает дальше и v6 с тем же sha256 больше не скачивает + +#### Scenario: Хаб недоступен во время selftest +- **WHEN** `selftest` новой версии вернул код 3 +- **THEN** `agent.py.new` удалён, `update.json` не изменён, следующая попытка — не раньше чем через 5 минут + +#### Scenario: Запуск без запускателя +- **WHEN** агент v5 запущен `python3 agent.py`, опубликована v6 +- **THEN** агент не скачивает файл и не выходит; в логе раз в час «доступна v6; для самообновления запускайте через + run.py»; `claim` несёт `launcher_version = null` + +#### Scenario: Подтверждение новой версии +- **WHEN** запускатель запустил v6, `update.json` = `trial` с `to_version = 6`, первый `claim` v6 успешен +- **THEN** `update.json` = `ok`; лог «обновление до v6 подтверждено» + +### Requirement: Запускатель run.py +`python3 run.py [аргументы]` SHALL работать с файлами в каталоге самого `run.py` (`agent.py`, `agent.py.new`, +`agent.py.prev`, `update.json`) и запускать дочерним процессом `agent.py run` с переданными аргументами, переменной +окружения `JIRA_AGENT_LAUNCHER = LAUNCHER_VERSION` и унаследованными stdout/stderr. Запускатель SHALL брать +неблокирующую блокировку `run.py.lock`; занята — «запускатель уже работает», код 1. SIGINT запускатель SHALL +игнорировать, SIGTERM — пересылать дочернему процессу. + +По коду выхода дочернего процесса: +- `0` или `130` — запускатель завершается с тем же кодом; +- `75` — при `update.json` = `staged` и совпадении sha256 `agent.py.new`: копия `agent.py` в `agent.py.prev`, атомарная + замена `agent.py` на `agent.py.new`, `update.json` = `trial`, запуск новой версии; иначе `agent.py.new` удаляется, + `update.json` = `rejected` «файл изменился после проверки», запуск текущей версии; +- иной код при `update.json` = `trial` — откат; иначе — перезапуск той же версии с паузой 5 с, удваиваемой до 300 с + при каждом следующем падении и сбрасываемой после 10 минут работы. + +Пока `update.json` = `trial`, запускатель SHALL ждать состояния `ok` не дольше 10 минут; по истечении — SIGTERM, через +60 с SIGKILL и откат. Откат: `agent.py.prev` возвращается на место `agent.py`, `update.json` = `rolled_back` с причиной +(«код через с» или «не вышла на связь за 10 мин»), прежняя версия запускается без паузы. Файл `agent.py` MUST +существовать на каждом шаге подмены. При старте запускатель SHALL выполнять подмену, если `update.json` = `staged` и `agent.py.new` есть, и удалять +`agent.py.new` без состояния `staged`. + +#### Scenario: Подмена версии +- **WHEN** дочерний v5 вышел с кодом 75, `update.json` = `staged` для v6, sha256 `agent.py.new` совпадает +- **THEN** `agent.py.prev` — прежний v5, `agent.py` — v6, `update.json` = `trial`, запущен v6 + +#### Scenario: Откат при падении на старте +- **WHEN** `update.json` = `trial` для v7, дочерний процесс вышел с кодом 1 через 3 с +- **THEN** `agent.py` — снова v6 (из `agent.py.prev`), `update.json` = `{state: rolled_back, to_version: 7, error: "код 1 + через 3 с"}`, запущен v6; v6 видит `rolled_back` для v7 с тем же sha256 и v7 больше не ставит + +#### Scenario: Новая версия не выходит на связь +- **WHEN** `update.json` = `trial`, дочерний процесс работает, но за 10 минут `ok` не записан +- **THEN** запускатель посылает SIGTERM, через 60 с при необходимости SIGKILL, делает откат с причиной «не вышла на связь + за 10 мин» + +#### Scenario: Падение после подтверждения +- **WHEN** `update.json` = `ok`, агент дважды подряд упал с кодом 1, проработав меньше минуты +- **THEN** запускатель перезапускает ту же версию через 5 с, затем через 10 с; отката нет + +#### Scenario: Остановка в терминале +- **WHEN** `python3 run.py` работает в терминале, нажат Ctrl-C +- **THEN** агент доделывает команду и выходит с кодом 0, запускатель завершается с кодом 0; повторный Ctrl-C — агент + выходит с 130, запускатель тоже + +#### Scenario: Второй запускатель +- **WHEN** `run.py` работает, и запускается ещё один `run.py` в том же каталоге +- **THEN** второй пишет «запускатель уже работает» и завершается с кодом 1, агента не запускает diff --git a/openspec/changes/agent-self-update/specs/hub-api/spec.md b/openspec/changes/agent-self-update/specs/hub-api/spec.md new file mode 100644 index 0000000..46ca294 --- /dev/null +++ b/openspec/changes/agent-self-update/specs/hub-api/spec.md @@ -0,0 +1,89 @@ +## MODIFIED Requirements + +### Requirement: Маршруты агента +Хаб SHALL предоставлять агенту следующие маршруты (все под агентским токеном): +- `POST /agent/claim` `{agent_version, settings_version, last_jira_call_at, update_state, launcher_version}` — долгий + запрос до 30 с; ответ `{command: {id, type, params, pause_seconds}, settings_version, agent_release}` или `{command: + null, settings_version, agent_release}`, где `agent_release` — `{version, sha256}` опубликованной версии агента или + `null`; обновляет `agent_status` (`last_jira_call_at = null` не затирает прежнее значение). `update_state` (объект или + `null`, `error` хранится до 500 символов) и `launcher_version` (целое или `null`) необязательны; хаб SHALL хранить + последние значения в памяти процесса, значения неверного типа игнорировать, не отвергая `claim`. Если есть команда в + `claimed`, `claim` SHALL выдать её снова (повторная выдача, см. `commands`), а не следующую из очереди. +- `GET /agent/settings` — настройки для агента (без токенов). +- `GET /agent/commands/{id}` — команда по id (для восстановления из журнала), включая `pause_seconds`. +- `POST /agent/commands/{id}/result` `{status, result, error, jira_calls, rate_limited}` — идемпотентно: повторный + отчёт по команде в терминальном статусе отвечает 200 без изменений, кроме позднего отчёта `done` с `result.key` по + `ensure_issue` в `cancelled` или `failed` без результата (результат записывается, статус не меняется, заметка `new:N` + переносится на ключ). Отчёт по команде в `queued` SHALL отвергаться 409 `not_claimed`. `error` хаб SHALL обрезать до + 1500 символов. +- `POST /agent/state` `{command_id, issues: [...], links: [...], neighbor_keys: [...], page, last_page}` — загрузка состояния + пачками; `neighbor_keys` — ключи вне базовой JQL, дочитанные по связям; хаб обновляет зеркало и `sync_run`. Каждая + пачка применяется независимо; `page` и `last_page` необязательны и хабом не используются. +- `GET /agent/known-keys` — ключи зеркала (для определения соседей). +- `GET /agent/release` — опубликованная версия агента `{version, sha256, content}` (`content` — текст `agent.py`); + нет публикации или файл не сходится с манифестом по sha256 — 404 `no_release`. + +#### Scenario: Выдача с подстановкой ключа +- **WHEN** следующая команда ссылается на `new:60`, а `ensure_issue` для `new:60` завершилась с `AURORA-1590` +- **THEN** `claim` отдаёт команду с `AURORA-1590` в параметрах + +#### Scenario: Повторный отчёт +- **WHEN** агент дважды прислал `result` для команды 15 +- **THEN** второй ответ 200, статус и результат не изменились + +#### Scenario: Отчёт по невыданной команде +- **WHEN** `POST /agent/commands/16/result` с `status = done`, команда 16 в `queued` +- **THEN** 409 `not_claimed`, команда осталась `queued` + +#### Scenario: Длинная ошибка +- **WHEN** агент прислал `failed` с `error` длиной 5000 символов +- **THEN** в `command.error` первые 1500 символов + +#### Scenario: Поздний отчёт после предела выдач +- **WHEN** `ensure_issue {temp_key: new:8, …}` переведена хабом в `failed` «агент не отчитался после 3 выдач», затем агент + прислал `done` с `result.key = AURORA-1630` +- **THEN** команда `failed`, в `result` — `AURORA-1630`; заметка `new:8` перенесена на `AURORA-1630` + +#### Scenario: Публикация в ответе claim +- **WHEN** опубликована v6, агент v5 отправил `claim`, команд нет +- **THEN** ответ `{command: null, settings_version, agent_release: {version: 6, sha256: …}}` + +#### Scenario: Состояние обновления от агента +- **WHEN** агент прислал в `claim` `update_state = {state: rolled_back, from_version: 6, to_version: 7, error: "код 1 через 3 с"}` + и `launcher_version = 1` +- **THEN** `GET /agent` отдаёт это `update_state`, `launcher_version = 1` и `update_hint` «откат с v7 на v6: код 1 через 3 с» + +#### Scenario: Скачивание версии +- **WHEN** агентским токеном `GET /agent/release`, опубликована v6 +- **THEN** 200 `{version: 6, sha256, content}`, sha256 от `content` в UTF-8 равен `sha256`; клиентским токеном — 401; + без публикации — 404 `no_release` + +## ADDED Requirements + +### Requirement: Публикация версии агента +`jira-hub agent publish PATH` SHALL публиковать файл агента: текст в UTF-8 не больше 1 МБ, ровно одна строка +`AGENT_VERSION = <целое>`, версия больше опубликованной. Файл SHALL записываться в `/release/agent.py` +(каталог 0700), затем `manifest.json` `{version, sha256, size, published_at}`; оба — через временный файл и +переименование, манифест последним. Команда SHALL работать без запущенного `serve` и печатать «опубликована v, +sha256 <первые 12 символов>…, байт»; при ошибке — сообщение и код 1, опубликованное не меняется. Хаб SHALL +читать манифест при каждом `claim`, `GET /agent/release` и `GET /agent`; битый манифест означает «публикации нет» +(предупреждение в лог). `GET /agent` SHALL дополняться полями `release` (`{version, sha256, published_at}` или `null`), +`update_state`, `launcher_version` и `update_hint`: «агент запущен без run.py — обновится только вручную» (опубликована +версия выше версии агента, агент онлайн, `launcher_version = null`), «откат с v на v: » +(`rolled_back`), «v отвергнута: » (`rejected`), иначе `null`. + +#### Scenario: Публикация новой версии +- **WHEN** опубликована v5, `jira-hub agent publish jira-agent/src/agent.py` с `AGENT_VERSION = 6` +- **THEN** в `release/` — новый `agent.py` и манифест с `version = 6`; вывод «опубликована v6, sha256 …»; код 0 + +#### Scenario: Версия не выше опубликованной +- **WHEN** опубликована v6, публикуется файл с `AGENT_VERSION = 6` +- **THEN** «опубликована v6, новая должна быть больше», код 1; `release/` не изменён + +#### Scenario: Нет строки версии +- **WHEN** публикуется файл без строки `AGENT_VERSION = <целое>` или с двумя такими строками +- **THEN** сообщение об ошибке, код 1; `release/` не изменён + +#### Scenario: Агент без запускателя +- **WHEN** опубликована v6, агент v5 онлайн, в последнем `claim` `launcher_version = null` +- **THEN** `GET /agent` → `update_hint` = «агент запущен без run.py — обновится только вручную» diff --git a/openspec/changes/agent-self-update/specs/mcp/spec.md b/openspec/changes/agent-self-update/specs/mcp/spec.md new file mode 100644 index 0000000..78ee1fa --- /dev/null +++ b/openspec/changes/agent-self-update/specs/mcp/spec.md @@ -0,0 +1,44 @@ +## MODIFIED Requirements + +### Requirement: Инструменты +| Инструмент | Параметры | Результат | +|---|---|---| +| `issues` | фильтры как у `GET /issues`, `limit` (по умолчанию 50) | таблица: ключ, тема (до 80 символов), статус, срок, исполнитель, спринт, статус по факту | +| `issue` | `key` | поля, связи (обе стороны, с типом и направлением), заметки, последние команды | +| `notes_set` | `key`, подмножество полей заметок | обновлённые заметки | +| `batch_create` | `title`, `commands` (список объектов по контракту `commands`) | `id`, `estimate_seconds`, число команд; 422 — текст ошибки валидации | +| `batch_status` | `id` | статусы команд, ошибки, оценка остатка | +| `batch_wait` | `id`, `timeout_minutes` (≤ 60) | как `batch_status`, возвращается по завершении или по таймауту | +| `batches` | `limit` | последние пакеты со статусом | +| `command_retry` | `id` | новый пакет | +| `batch_cancel` | `id` | вид пакета; для команд пакета, уже взятых агентом, — строка «команда #N уже у агента и будет выполнена; остановить — `POST /commands/N/cancel` с `force: true`» | +| `refresh` | `keys` или `full`, `wait` (bool) | без `wait` — строка с id поставленного пакета; с `wait` — итог пакета, как у `batch_wait` | +| `agent_status` | — | онлайн/офлайн, версия, текущая команда, довыданные команды (id, тип, число выдач), 429 за сутки, версия настроек; публикация агента («публикация: v от <дата>» или «публикации нет»), состояние обновления («обновление: v → v»), запускатель («запускатель: v» или «без запускателя»), `update_hint`, если он есть | +| `sync_log` | `limit` | последние `sync_run` | +| `settings_get`, `settings_set` | ключ, значение | текущее значение / новая `settings_version` | + +Описание инструмента `batch_create` SHALL предупреждать, что `ensure_issue` должна нести `labels` с базовой меткой +проекта (`AWPCA`): хаб и агент метки сами не добавляют, и задача без неё выпадает из базовой JQL и из зеркала. + +Автор команд из MCP SHALL быть `claude`; из API без автора — `api`. + +#### Scenario: Пакет из чата +- **WHEN** `batch_create {title: "1538 в спринт 2", commands: [ensure_fields{…}, ensure_link{…}, ensure_sprint{…}]}` +- **THEN** пакет создан с четырьмя командами (три + `refresh`), ответ содержит оценку в минутах + +#### Scenario: Ожидание результата +- **WHEN** `batch_wait {id, timeout_minutes: 10}` и пакет завершился через 6 минут +- **THEN** ответ через 6 минут со статусами всех команд + +#### Scenario: Статус агента с довыдачей +- **WHEN** агент на связи, команда 40 выдана дважды и ещё без отчёта +- **THEN** ответ `agent_status` содержит «агент онлайн» и строку о команде 40 с числом выдач 2 + +#### Scenario: Отмена пакета с командой у агента +- **WHEN** `batch_cancel {id: 9}`, команда 30 пакета в `claimed` +- **THEN** ответ содержит вид пакета и строку «команда #30 уже у агента и будет выполнена; остановить — …» + +#### Scenario: Статус агента после отката +- **WHEN** опубликована v7, агент v6 под запускателем v1 прислал `update_state = rolled_back` для v7 +- **THEN** ответ `agent_status` содержит «публикация: v7 от …», «обновление: rolled_back v6 → v7», «запускатель: v1» и + «откат с v7 на v6: …»