From ce148b69f9ca78949cab819e43eb8a79871960ca Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 14 Oct 2026 21:49:21 +0400 Subject: [PATCH 1/7] =?UTF-8?q?docs(openspec):=20agent-self-update=20?= =?UTF-8?q?=E2=80=94=20proposal,=20design,=20=D0=B4=D0=B5=D0=BB=D1=8C?= =?UTF-8?q?=D1=82=D1=8B=20agent/hub-api/mcp=20(#22)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../changes/agent-self-update/.openspec.yaml | 2 + openspec/changes/agent-self-update/design.md | 194 ++++++++++++++++++ .../changes/agent-self-update/proposal.md | 65 ++++++ .../agent-self-update/specs/agent/spec.md | 133 ++++++++++++ .../agent-self-update/specs/hub-api/spec.md | 89 ++++++++ .../agent-self-update/specs/mcp/spec.md | 44 ++++ 6 files changed, 527 insertions(+) create mode 100644 openspec/changes/agent-self-update/.openspec.yaml create mode 100644 openspec/changes/agent-self-update/design.md create mode 100644 openspec/changes/agent-self-update/proposal.md create mode 100644 openspec/changes/agent-self-update/specs/agent/spec.md create mode 100644 openspec/changes/agent-self-update/specs/hub-api/spec.md create mode 100644 openspec/changes/agent-self-update/specs/mcp/spec.md 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: …» -- 2.47.2 From 4e8b32310eb0025fae9ac3763376dedce340b027 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 14 Oct 2026 21:59:03 +0400 Subject: [PATCH 2/7] =?UTF-8?q?docs(plan):=20agent-self-update=20=E2=80=94?= =?UTF-8?q?=20=D0=BF=D0=BB=D0=B0=D0=BD=20=D1=85=D0=B0=D0=B1=D0=B0=20(#22)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../plans/2026-10-14-agent-self-update-hub.md | 615 ++++++++++++++++++ 1 file changed, 615 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-14-agent-self-update-hub.md diff --git a/docs/superpowers/plans/2026-10-14-agent-self-update-hub.md b/docs/superpowers/plans/2026-10-14-agent-self-update-hub.md new file mode 100644 index 0000000..4cfb155 --- /dev/null +++ b/docs/superpowers/plans/2026-10-14-agent-self-update-hub.md @@ -0,0 +1,615 @@ +# agent-self-update (хаб) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** хаб публикует версию агента (`jira-hub agent publish`), сообщает её в `claim`, отдаёт файл по `GET /agent/release` и показывает состояние обновления в `GET /agent` и MCP `agent_status`. + +**Architecture:** новый модуль `release.py` — файлы `<данные>/release/agent.py` + `manifest.json`, без БД; `api.Hub` держит последние `update_state`/`launcher_version` агента в памяти; `h_claim`, `h_agent_get` и новый `h_agent_release` читают манифест на каждый запрос; MCP только форматирует новые поля `GET /agent`. + +**Tech Stack:** Python 3.13, uv, pytest; stdlib (`hashlib`, `json`, `os.replace`). + +**Spec:** `openspec/changes/agent-self-update/` (proposal.md, design.md решения 1–6, 21–24; specs/hub-api, specs/mcp). Агентская часть — отдельный план `jira-agent/docs/superpowers/plans/2026-10-14-agent-self-update-agent.md`. + +**Worktree:** `/Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/self-update`, ветка `feat/22-agent-self-update`. Все команды — из этого каталога; git — `git -C /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/self-update …`. Параллельно в `.wt/web-app` идёт #21 — его не трогать. + +## Global Constraints + +- Публикация — только вверх: «опубликована v, новая должна быть больше». +- Файл агента: UTF-8, не больше 1 МБ (1 048 576 байт), ровно одна строка `AGENT_VERSION = <целое>`. +- Каталог `release/` — права 0700 (`paths.secure_dir`); `agent.py`, затем `manifest.json` — через временный файл и `os.replace`, манифест последним. +- `manifest.json` = `{version, sha256, size, published_at}`; sha256 — от байтов файла (hex). +- Вывод публикации: `опубликована v, sha256 <первые 12 символов>…, байт`; ошибка — сообщение в stderr, код 1. +- `GET /agent/release` — агентский токен; нет публикации или sha не сходится — `404 {error: "no_release"}`. +- `update_state.error` хранится не больше 500 символов; значения неверного типа в `claim` игнорируются, `claim` не отвергается. +- `update_hint` (в порядке проверки): «агент запущен без run.py — обновится только вручную»; «откат с v на v: »; «v отвергнута: »; иначе `null`. +- Схема БД не меняется. Язык сообщений, комментариев, коммитов — русский. Коммит заканчивается строкой `Co-Authored-By: Claude Opus 5.5 `. +- Весь набор: `uv run pytest` — зелёным считается только весь набор. + +## Review Focus + +- Манифест есть, а `agent.py` ещё старый (публикация в процессе) или файл подменён руками — `GET /agent/release` должен дать 404, а не отдать файл с чужим sha (тест в Task 1: `test_load_rejects_sha_mismatch`). +- Битый `manifest.json` (обрезан, не JSON) — хаб не падает, считает «публикации нет» (тест в Task 1: `test_current_broken_manifest`). +- Агент v4 (старый) шлёт `claim` без `update_state`/`launcher_version` — `claim` работает, `launcher_version = null` (тест в Task 2: `test_claim_without_update_fields`). +- `update_state` строкой или `launcher_version` строкой/булевым — не 400/500, а игнор с сохранением прежнего (тест в Task 2: `test_claim_bad_update_types_ignored`). +- `agent_version` в `agent_status` — строка (`TEXT`), сравнение с `release.version` числом; `None` у нового хаба до первого `claim` (тест в Task 2: `test_hint_without_launcher`, `test_hint_none_when_no_agent`). + +--- + +### Task 1: Модуль `release` и `jira-hub agent publish` + +**Files:** +- Create: `src/jira_hub/release.py` +- Modify: `src/jira_hub/paths.py` (функция `release_dir`) +- Modify: `src/jira_hub/cli.py` (подкоманда `agent publish`) +- Test: `tests/test_release.py` (новый), `tests/test_cli.py` + +**Interfaces:** +- Produces: + - `release.PublishError(Exception)` + - `release.parse_version(text: str) -> int` + - `release.publish(release_dir: Path | str, source: Path | str) -> dict` — манифест `{version, sha256, size, published_at}` + - `release.current(release_dir) -> dict | None` — `{version: int, sha256: str, published_at: str | None}` + - `release.load(release_dir) -> dict | None` — `{version, sha256, content: str}` + - `release.hint(rel: dict | None, agent: dict, update_state: dict | None, launcher_version: int | None) -> str | None` + - `paths.release_dir() -> Path` = `home() / "release"` + +- [ ] **Step 1: Write the failing tests** — `tests/test_release.py`: + +```python +import json +import os +import stat + +import pytest + +from jira_hub import release + + +def agent_file(tmp_path, version, body="print('x')\n", name="agent.py"): + p = tmp_path / name + p.write_text(f'#!/usr/bin/env python3\n"""агент"""\nAGENT_VERSION = {version}\n{body}', encoding="utf-8") + return p + + +def test_publish_writes_file_and_manifest(tmp_path): + rd = tmp_path / "release" + src = agent_file(tmp_path, 6) + m = release.publish(rd, src) + assert m["version"] == 6 and m["size"] == src.stat().st_size and len(m["sha256"]) == 64 + assert (rd / "agent.py").read_bytes() == src.read_bytes() + assert json.loads((rd / "manifest.json").read_text(encoding="utf-8")) == m + assert stat.S_IMODE(os.stat(rd).st_mode) == 0o700 + assert release.current(rd) == {"version": 6, "sha256": m["sha256"], "published_at": m["published_at"]} + loaded = release.load(rd) + assert loaded == {"version": 6, "sha256": m["sha256"], "content": src.read_text(encoding="utf-8")} + assert not [p.name for p in rd.iterdir() if p.name.startswith(".")] # временных файлов не осталось + + +def test_publish_only_up(tmp_path): + rd = tmp_path / "release" + release.publish(rd, agent_file(tmp_path, 6)) + before = (rd / "manifest.json").read_bytes() + with pytest.raises(release.PublishError, match="опубликована v6, новая должна быть больше"): + release.publish(rd, agent_file(tmp_path, 6, body="print('y')\n")) + with pytest.raises(release.PublishError, match="опубликована v6"): + release.publish(rd, agent_file(tmp_path, 5)) + assert (rd / "manifest.json").read_bytes() == before + assert release.publish(rd, agent_file(tmp_path, 7))["version"] == 7 + + +@pytest.mark.parametrize("text", ["print(1)\n", "AGENT_VERSION = 5\nAGENT_VERSION = 6\n", "AGENT_VERSION = 'v5'\n", + " AGENT_VERSION = 5\n"]) +def test_version_line_required_once(tmp_path, text): + p = tmp_path / "a.py" + p.write_text(text, encoding="utf-8") + with pytest.raises(release.PublishError, match="AGENT_VERSION"): + release.publish(tmp_path / "release", p) + assert not (tmp_path / "release" / "manifest.json").exists() + + +def test_publish_rejects_non_utf8_and_big(tmp_path): + p = tmp_path / "a.py" + p.write_bytes(b"AGENT_VERSION = 5\n\xff\xfe") + with pytest.raises(release.PublishError, match="UTF-8"): + release.publish(tmp_path / "release", p) + p.write_bytes(b"AGENT_VERSION = 5\n" + b"#" * (1024 * 1024)) + with pytest.raises(release.PublishError, match="1 МБ"): + release.publish(tmp_path / "release", p) + with pytest.raises(release.PublishError, match="не прочитан"): + release.publish(tmp_path / "release", tmp_path / "нет.py") + + +def test_current_none_without_release(tmp_path): + assert release.current(tmp_path / "release") is None + assert release.load(tmp_path / "release") is None + + +def test_current_broken_manifest(tmp_path): + rd = tmp_path / "release" + rd.mkdir() + (rd / "manifest.json").write_text('{"version": 6, "sha', encoding="utf-8") + assert release.current(rd) is None + (rd / "manifest.json").write_text('{"version": "шесть", "sha256": "x"}', encoding="utf-8") + assert release.current(rd) is None + + +def test_load_rejects_sha_mismatch(tmp_path): + rd = tmp_path / "release" + release.publish(rd, agent_file(tmp_path, 6)) + (rd / "agent.py").write_text("AGENT_VERSION = 6\nподменён\n", encoding="utf-8") + assert release.current(rd)["version"] == 6 + assert release.load(rd) is None + + +def test_hint(): + rel = {"version": 6, "sha256": "s", "published_at": "t"} + online5 = {"agent_version": "5", "online": True} + assert release.hint(rel, online5, None, None) == "агент запущен без run.py — обновится только вручную" + assert release.hint(rel, online5, None, 1) is None + assert release.hint(rel, {"agent_version": "5", "online": False}, None, None) is None + assert release.hint(rel, {"agent_version": "6", "online": True}, None, None) is None + assert release.hint(None, online5, None, None) is None + rb = {"state": "rolled_back", "from_version": 6, "to_version": 7, "error": "код 1 через 3 с"} + assert release.hint(rel, {"agent_version": "6", "online": True}, rb, 1) == "откат с v7 на v6: код 1 через 3 с" + rj = {"state": "rejected", "from_version": 5, "to_version": 6, "error": "sha256 не сходится"} + assert release.hint(rel, online5, rj, 1) == "v6 отвергнута: sha256 не сходится" + assert release.hint(rel, online5, {"state": "ok", "from_version": 5, "to_version": 6}, 1) is None + + +def test_hint_none_when_no_agent(): + rel = {"version": 6, "sha256": "s", "published_at": "t"} + assert release.hint(rel, {"agent_version": None, "online": False}, None, None) is None +``` + +Добавить в `tests/test_cli.py`: + +```python +def test_agent_publish(tmp_path, monkeypatch, capsys): + monkeypatch.setenv("JIRA_HUB_HOME", str(tmp_path / "home")) + src = tmp_path / "agent.py" + src.write_text("AGENT_VERSION = 6\n", encoding="utf-8") + assert cli.main(["agent", "publish", str(src)]) == 0 + out = capsys.readouterr().out + assert out.startswith("опубликована v6, sha256 ") and "…, 18 байт" in out + assert (paths.release_dir() / "manifest.json").exists() + assert cli.main(["agent", "publish", str(src)]) == 1 + assert "опубликована v6, новая должна быть больше" in capsys.readouterr().err +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_release.py tests/test_cli.py -q` +Expected: FAIL — `ImportError: cannot import name 'release'` и `invalid choice: 'agent'`. + +- [ ] **Step 3: Implement** — `src/jira_hub/paths.py`, после `backups_dir`: + +```python +def release_dir() -> Path: + return home() / "release" +``` + +`src/jira_hub/release.py`: + +```python +"""Опубликованная версия агента для самообновления: <данные>/release/agent.py + manifest.json, без БД. +Спека hub-api «Публикация версии агента», design agent-self-update решения 1–2, 5.""" +import hashlib +import json +import logging +import os +import re +from datetime import datetime, timezone +from pathlib import Path + +from .paths import secure_dir + +log = logging.getLogger(__name__) + +MAX_SIZE = 1024 * 1024 +VERSION_RE = re.compile(r"^AGENT_VERSION = (\d+)[ \t]*\r?$", re.M) + + +class PublishError(Exception): + pass + + +def parse_version(text: str) -> int: + found = VERSION_RE.findall(text) + if len(found) != 1: + raise PublishError(f"в файле должна быть ровно одна строка AGENT_VERSION = <целое>, найдено {len(found)}") + return int(found[0]) + + +def _write_atomic(path: Path, data: bytes) -> None: + tmp = path.with_name("." + path.name + ".tmp") + tmp.write_bytes(data) + os.replace(tmp, path) + + +def publish(release_dir, source) -> dict: + """Опубликовать файл агента: только вверх; agent.py, затем манифест (по нему версия считается опубликованной).""" + src = Path(source) + try: + data = src.read_bytes() + except OSError as e: + raise PublishError(f"файл не прочитан {src}: {e}") from e + if len(data) > MAX_SIZE: + raise PublishError(f"файл больше 1 МБ ({len(data)} байт)") + try: + text = data.decode("utf-8") + except UnicodeDecodeError as e: + raise PublishError("файл не в UTF-8") from e + version = parse_version(text) + rd = Path(release_dir) + prev = current(rd) + if prev and version <= prev["version"]: + raise PublishError(f"опубликована v{prev['version']}, новая должна быть больше") + secure_dir(rd) + manifest = {"version": version, "sha256": hashlib.sha256(data).hexdigest(), "size": len(data), + "published_at": datetime.now(timezone.utc).replace(microsecond=0).isoformat()} + _write_atomic(rd / "agent.py", data) + _write_atomic(rd / "manifest.json", json.dumps(manifest, ensure_ascii=False).encode("utf-8")) + return manifest + + +def current(release_dir) -> dict | None: + """Опубликованная версия по манифесту; нет или битый манифест — None (агенту нечего обновлять).""" + path = Path(release_dir) / "manifest.json" + if not path.exists(): + return None + try: + m = json.loads(path.read_text(encoding="utf-8")) + return {"version": int(m["version"]), "sha256": str(m["sha256"]), "published_at": m.get("published_at")} + except (OSError, ValueError, KeyError, TypeError) as e: + log.warning("release: манифест %s не читается (%s) — публикации нет", path, e) + return None + + +def load(release_dir) -> dict | None: + """Файл агента для GET /agent/release; sha256 пересчитывается — несовпадение с манифестом = публикации нет.""" + m = current(release_dir) + if m is None: + return None + try: + data = (Path(release_dir) / "agent.py").read_bytes() + except OSError as e: + log.error("release: agent.py не прочитан: %s", e) + return None + if hashlib.sha256(data).hexdigest() != m["sha256"]: + log.error("release: agent.py не сходится с манифестом по sha256 — публикации нет") + return None + return {"version": m["version"], "sha256": m["sha256"], "content": data.decode("utf-8")} + + +def hint(rel, agent, update_state, launcher_version) -> str | None: + """Подсказка для agent_status (design решение 5).""" + try: + agent_version = int(agent.get("agent_version") or 0) + except (TypeError, ValueError): + agent_version = 0 + if rel and agent.get("online") and agent_version and rel["version"] > agent_version and launcher_version is None: + return "агент запущен без run.py — обновится только вручную" + us = update_state or {} + if us.get("state") == "rolled_back": + return f"откат с v{us.get('to_version')} на v{us.get('from_version')}: {us.get('error') or '—'}" + if us.get("state") == "rejected": + return f"v{us.get('to_version')} отвергнута: {us.get('error') or '—'}" + return None +``` + +`src/jira_hub/cli.py`: в docstring модуля добавить `agent publish`; после `sub.add_parser("tokens", …)`: + +```python + p = sub.add_parser("agent", help="версия агента для самообновления") + agent_sub = p.add_subparsers(dest="agent_cmd", required=True) + p = agent_sub.add_parser("publish", help="опубликовать agent.py (версия только вверх)") + p.add_argument("path") +``` + +и сразу после `args = ap.parse_args(argv)` (до `serve`; БД не нужна): + +```python + if args.cmd == "agent": + from . import release + try: + m = release.publish(paths.release_dir(), args.path) + except release.PublishError as e: + print(e, file=sys.stderr) + return 1 + print(f"опубликована v{m['version']}, sha256 {m['sha256'][:12]}…, {m['size']} байт") + return 0 +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/test_release.py tests/test_cli.py -q` +Expected: PASS. Затем весь набор `uv run pytest -q` — PASS. + +- [ ] **Step 5: Commit** + +```bash +git -C /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/self-update add src/jira_hub/release.py src/jira_hub/paths.py src/jira_hub/cli.py tests/test_release.py tests/test_cli.py +git -C /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/self-update commit -m "feat(hub): публикация версии агента — release.py и jira-hub agent publish (#22) + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 2: API — `agent_release` в claim, `GET /agent/release`, состояние обновления в `GET /agent` + +**Files:** +- Modify: `src/jira_hub/api.py` (`Hub.__init__`, новый `Hub.note_agent_update`, `h_claim`, новый `h_agent_release`, `h_agent_get`, `ROUTES`) +- Test: `tests/test_api.py` + +**Interfaces:** +- Consumes: `release.current`, `release.load`, `release.hint` (Task 1). +- Produces: + - `api.Hub(db_path, backups_dir=None, release_dir=None)`; `hub.release_dir: Path` (по умолчанию `db_path.parent / "release"`); `hub.agent_update: dict` = `{"update_state": dict | None, "launcher_version": int | None}` + - ответ `POST /agent/claim`: `{command, settings_version, agent_release: {version, sha256} | None}` + - `GET /agent/release` → `{version, sha256, content}` | 404 `no_release` + - `GET /agent`: прежние поля + `release` (`{version, sha256, published_at}` | None), `update_state`, `launcher_version`, `update_hint` + +- [ ] **Step 1: Write the failing tests** — в `tests/test_api.py` (фикстура `hub` уже есть; `Hub` в ней создаётся с `tmp_path / "hub.sqlite"`, значит `release_dir` = `tmp_path / "release"`): + +```python +from jira_hub import release + + +def _publish(hub, tmp_path, version): + src = tmp_path / f"agent{version}.py" + src.write_text(f"AGENT_VERSION = {version}\nprint('v{version}')\n", encoding="utf-8") + return release.publish(hub["hub"].release_dir, src) + + +def test_claim_release_field(hub, tmp_path): + a = hub["agent"] + assert a.post("/agent/claim", {"agent_version": 5})["agent_release"] is None + m = _publish(hub, tmp_path, 6) + r = a.post("/agent/claim", {"agent_version": 5}) + assert r["command"] is None and r["agent_release"] == {"version": 6, "sha256": m["sha256"]} + + +def test_agent_release_route(hub, tmp_path): + with pytest.raises(HubError) as e: + hub["agent"].get("/agent/release") + assert e.value.status == 404 and e.value.error == "no_release" + m = _publish(hub, tmp_path, 6) + r = hub["agent"].get("/agent/release") + assert r == {"version": 6, "sha256": m["sha256"], "content": "AGENT_VERSION = 6\nprint('v6')\n"} + with pytest.raises(HubError) as e: + hub["client"].get("/agent/release") + assert e.value.status == 401 + + +def test_claim_update_state_shown_in_agent(hub, tmp_path): + _publish(hub, tmp_path, 7) + us = {"state": "rolled_back", "from_version": 6, "to_version": 7, "sha256": "s", "error": "код 1 через 3 с" + "x" * 600, + "at": "2026-10-14T20:00:00+00:00"} + hub["agent"].post("/agent/claim", {"agent_version": 6, "update_state": us, "launcher_version": 1}) + v = hub["client"].get("/agent") + assert v["release"]["version"] == 7 and v["release"]["published_at"] + assert v["launcher_version"] == 1 + assert v["update_state"]["state"] == "rolled_back" and len(v["update_state"]["error"]) == 500 + assert v["update_hint"].startswith("откат с v7 на v6: код 1 через 3 с") + + +def test_claim_without_update_fields(hub, tmp_path): + hub["agent"].post("/agent/claim", {"agent_version": 6, "update_state": {"state": "ok", "from_version": 5, + "to_version": 6}, "launcher_version": 1}) + hub["agent"].post("/agent/claim", {"agent_version": 4}) # агент v4: полей нет + v = hub["client"].get("/agent") + assert v["launcher_version"] is None and v["update_state"] is None + + +def test_claim_bad_update_types_ignored(hub): + a = hub["agent"] + a.post("/agent/claim", {"agent_version": 5, "update_state": {"state": "ok"}, "launcher_version": 1}) + r = a.post("/agent/claim", {"agent_version": 5, "update_state": "ok", "launcher_version": "1"}) + assert "command" in r + a.post("/agent/claim", {"agent_version": 5, "update_state": {"state": "ok"}, "launcher_version": True}) + v = hub["client"].get("/agent") + assert v["update_state"] == {"state": "ok"} and v["launcher_version"] == 1 + + +def test_hint_without_launcher(hub, tmp_path): + _publish(hub, tmp_path, 6) + hub["agent"].post("/agent/claim", {"agent_version": 5}) + v = hub["client"].get("/agent") + assert v["online"] is True and v["update_hint"] == "агент запущен без run.py — обновится только вручную" + + +def test_agent_view_without_release(hub): + v = hub["client"].get("/agent") + assert v["release"] is None and v["update_state"] is None and v["launcher_version"] is None and v["update_hint"] is None +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_api.py -q -k "release or update or hint or without"` +Expected: FAIL — `KeyError: 'agent_release'`, 404 `not_found` на `/agent/release`, `AttributeError: 'Hub' object has no attribute 'release_dir'`. + +- [ ] **Step 3: Implement** — `src/jira_hub/api.py`: + +импорт: `from . import backup, db, mirror, queue, relay, release, settings` + +`Hub.__init__`: + +```python + def __init__(self, db_path, backups_dir=None, release_dir=None): + self.db_path = Path(db_path) + self.backups_dir = Path(backups_dir) if backups_dir else default_backups_dir() + self.release_dir = Path(release_dir) if release_dir else self.db_path.parent / "release" + self.agent_update = {"update_state": None, "launcher_version": None} # последнее из claim; в памяти (R-3) + self.claim_wait = 30 + ... +``` + +метод `Hub`: + +```python + def note_agent_update(self, body): + """update_state и launcher_version из claim: хранятся в памяти, агент шлёт их в каждом claim (R-3). + Значение неверного типа игнорируется — claim из-за него не отвергается.""" + us = body.get("update_state") + if us is None or isinstance(us, dict): + if us is not None and isinstance(us.get("error"), str): + us = {**us, "error": us["error"][:500]} + self.agent_update["update_state"] = us + lv = body.get("launcher_version") + if lv is None or (isinstance(lv, int) and not isinstance(lv, bool)): + self.agent_update["launcher_version"] = lv +``` + +`h_claim`: + +```python +def h_claim(hub, conn, m, q, body): + hub.note_agent_update(body) + deadline = time.monotonic() + hub.claim_wait + while True: + cmd = queue.claim(conn, body.get("agent_version"), body.get("settings_version"), body.get("last_jira_call_at"), + backup=hub.make_backup) + if cmd or time.monotonic() >= deadline: + break + time.sleep(1) + rel = release.current(hub.release_dir) + return 200, {"command": cmd, "settings_version": settings.get(conn, "settings_version"), + "agent_release": {"version": rel["version"], "sha256": rel["sha256"]} if rel else None} + + +def h_agent_release(hub, conn, m, q, body): + rel = release.load(hub.release_dir) + if rel is None: + raise ApiError(404, "no_release", "версия агента не опубликована") + return 200, rel +``` + +`h_agent_get`: + +```python +def h_agent_get(hub, conn, m, q, body): + view = queue.agent_view(conn) + rel = release.current(hub.release_dir) + us, lv = hub.agent_update["update_state"], hub.agent_update["launcher_version"] + view.update(release=rel, update_state=us, launcher_version=lv, update_hint=release.hint(rel, view, us, lv)) + return 200, view +``` + +`ROUTES` — после `known-keys`: + +```python + ("GET", r"^/agent/release$", h_agent_release, "agent"), +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest -q` +Expected: весь набор PASS. + +- [ ] **Step 5: Commit** + +```bash +git -C /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/self-update add src/jira_hub/api.py tests/test_api.py +git -C /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/self-update commit -m "feat(hub): agent_release в claim, GET /agent/release, состояние обновления в GET /agent (#22) + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 3: MCP `agent_status` и документация хаба + +**Files:** +- Modify: `src/jira_hub/mcp_server.py` (`fmt_agent`, docstring `agent_status`) +- Modify: `README.md` (новый раздел «Самообновление агента» после «Транспорт через общую папку»; строка `GET /agent/release` в «HTTP API») +- Modify: `ARCHITECTURE.md` (строка модуля `release`, `cli` — `agent publish`, поток данных) +- Modify: `CLAUDE.md` (данные `release/`, команда `agent publish`, текущий change) +- Test: `tests/test_mcp.py` + +**Interfaces:** +- Consumes: поля `GET /agent` из Task 2: `release`, `update_state`, `launcher_version`, `update_hint`. + +- [ ] **Step 1: Write the failing test** — в `tests/test_mcp.py`: + +```python +def test_fmt_agent_update_lines(): + base = {"online": True, "agent_version": "6", "current_command_id": None, "rate_limited_24h": 0, "redelivered": [], + "warning": None, "last_seen_at": "2026-10-14T20:00:00+00:00", "settings_version": 3} + text = mcp_server.fmt_agent({**base, "release": {"version": 7, "sha256": "s", "published_at": "2026-10-14T19:00:00+00:00"}, + "update_state": {"state": "rolled_back", "from_version": 6, "to_version": 7}, + "launcher_version": 1, "update_hint": "откат с v7 на v6: код 1 через 3 с"}) + assert "публикация: v7 от 2026-10-14T19:00:00+00:00" in text + assert "обновление: rolled_back v6 → v7" in text + assert "запускатель: v1" in text and "откат с v7 на v6: код 1 через 3 с" in text + text = mcp_server.fmt_agent({**base, "release": None, "update_state": None, "launcher_version": None, "update_hint": None}) + assert "публикации нет" in text and "без запускателя" in text and "обновление:" not in text +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/test_mcp.py -q -k fmt_agent_update` +Expected: FAIL — `AssertionError` (строк «публикация» нет). + +- [ ] **Step 3: Implement** — `fmt_agent` в `src/jira_hub/mcp_server.py`, перед `return`: + +```python + rel = a.get("release") + out.append(f"публикация: v{rel['version']} от {_v(rel.get('published_at'))}" if rel else "публикации нет") + us = a.get("update_state") + if us: + out.append(f"обновление: {us.get('state')} v{_v(us.get('from_version'))} → v{_v(us.get('to_version'))}") + lv = a.get("launcher_version") + out.append(f"запускатель: v{lv}" if lv is not None else "без запускателя") + if a.get("update_hint"): + out.append(a["update_hint"]) +``` + +docstring `agent_status`: `"""Агент: онлайн/офлайн, версия, текущая команда, 429 за сутки, команды без ответа, опубликованная версия, +состояние самообновления и запускатель."""` + +Документация (текст — русский, как в соседних разделах): + +`README.md`, новый раздел после «Транспорт через общую папку»: + +```markdown +## Самообновление агента + +Агент v5+ под запускателем `run.py` обновляется сам (спека `openspec/specs/agent/`, «Самообновление агента»). +Опубликовать новую версию: + + uv run jira-hub agent publish ../jira-agent/src/agent.py + +Версия берётся из строки `AGENT_VERSION = N` и должна быть больше опубликованной; файл и `manifest.json` ложатся в +`~/.local/share/jira-hub/release/` (0700). Перезапуск хаба не нужен: версия уходит агенту в ответе `claim` +(`agent_release`), файл — по `GET /agent/release`. Агент ставит её в паузе между командами; через несколько минут +`agent_status` (MCP) покажет «обновление: ok v<старая> → v<новая>». Если новая версия упала или не вышла на связь, +запускатель вернёт прежнюю — `agent_status` покажет «откат с v …»; исправление публикуется следующим номером. +Снять публикацию нельзя: откат «назад» — тот же старый код с новым номером. +``` + +`README.md`, раздел «HTTP API» — в список маршрутов агента добавить `GET /agent/release` — опубликованная версия агента (`{version, sha256, content}`, 404 `no_release`); у `POST /agent/claim` — поле ответа `agent_release` и поля тела `update_state`, `launcher_version`; у `GET /agent` — `release`, `update_state`, `launcher_version`, `update_hint`. + +`ARCHITECTURE.md`: строка таблицы после `relay`: + +```markdown +| `release` | опубликованная версия агента: `release/agent.py` + `manifest.json` в каталоге данных, `publish` (только вверх), `current`, `load` (с проверкой sha256), подсказка `hint` для `agent_status` | `paths` | +``` + +у `api` в «Зависит от» добавить `release`; у `cli` — `agent publish` в ответственности и `release` в зависимостях; в «Потоки данных» строку: «Самообновление агента: `jira-hub agent publish` → `release/` → `claim.agent_release` → агент скачивает `GET /agent/release`; состояние обновления агент шлёт в `claim`, хаб держит в памяти и отдаёт в `GET /agent`.» + +`CLAUDE.md`: в «Стек и ограничения», строка «Данные» — добавить `release/` (опубликованная версия агента, 0700); в «Команды» — `uv run jira-hub agent publish PATH` — опубликовать версию агента для самообновления; в «Что это» заменить «текущего change нет (последний — …viewer-live/)» на «текущий change — `openspec/changes/agent-self-update/`». + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest -q` и `openspec validate agent-self-update --strict` +Expected: весь набор PASS; `Change 'agent-self-update' is valid`. + +- [ ] **Step 5: Commit** + +```bash +git -C /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/self-update add src/jira_hub/mcp_server.py tests/test_mcp.py README.md ARCHITECTURE.md CLAUDE.md +git -C /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/self-update commit -m "feat(hub): agent_status — публикация и самообновление агента; README, ARCHITECTURE (#22) + +Co-Authored-By: Claude Opus 5.5 " +``` -- 2.47.2 From eb66c2144a09fdc11980d19bf991a10d4998bb32 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 14 Oct 2026 22:09:28 +0400 Subject: [PATCH 3/7] =?UTF-8?q?feat(hub):=20=D0=BF=D1=83=D0=B1=D0=BB=D0=B8?= =?UTF-8?q?=D0=BA=D0=B0=D1=86=D0=B8=D1=8F=20=D0=B2=D0=B5=D1=80=D1=81=D0=B8?= =?UTF-8?q?=D0=B8=20=D0=B0=D0=B3=D0=B5=D0=BD=D1=82=D0=B0=20=E2=80=94=20rel?= =?UTF-8?q?ease.py=20=D0=B8=20jira-hub=20agent=20publish=20(#22)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/jira_hub/cli.py | 17 ++++++- src/jira_hub/paths.py | 4 ++ src/jira_hub/release.py | 104 ++++++++++++++++++++++++++++++++++++++++ tests/test_cli.py | 12 +++++ tests/test_release.py | 103 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 239 insertions(+), 1 deletion(-) create mode 100644 src/jira_hub/release.py create mode 100644 tests/test_release.py diff --git a/src/jira_hub/cli.py b/src/jira_hub/cli.py index 7a51962..ce90d04 100644 --- a/src/jira_hub/cli.py +++ b/src/jira_hub/cli.py @@ -1,4 +1,5 @@ -"""Команды jira-hub: init (настройки из config.example.json), serve (HTTP), import (CSV-пакет), mcp (stdio), tokens.""" +"""Команды jira-hub: init (настройки из config.example.json), serve (HTTP), import (CSV-пакет), mcp (stdio), tokens, +agent publish (версия агента для самообновления).""" import argparse import json import sys @@ -19,8 +20,22 @@ def main(argv=None) -> int: p.add_argument("folder") sub.add_parser("mcp", help="MCP-сервер по stdio") sub.add_parser("tokens", help="показать токены агента и клиента") + p = sub.add_parser("agent", help="версия агента для самообновления") + agent_sub = p.add_subparsers(dest="agent_cmd", required=True) + p = agent_sub.add_parser("publish", help="опубликовать agent.py (версия только вверх)") + p.add_argument("path") args = ap.parse_args(argv) + if args.cmd == "agent": + from . import release + try: + m = release.publish(paths.release_dir(), args.path) + except release.PublishError as e: + print(e, file=sys.stderr) + return 1 + print(f"опубликована v{m['version']}, sha256 {m['sha256'][:12]}…, {m['size']} байт") + return 0 + if args.cmd == "serve": from . import api hub = api.Hub(paths.db_path()) diff --git a/src/jira_hub/paths.py b/src/jira_hub/paths.py index 159f9bc..b68ff1d 100644 --- a/src/jira_hub/paths.py +++ b/src/jira_hub/paths.py @@ -18,6 +18,10 @@ def backups_dir() -> Path: return home() / "backups" +def release_dir() -> Path: + return home() / "release" + + def secure_dir(path: Path) -> None: """Создать каталог и закрыть его владельцу (0700): в базе и копиях лежат токены (M-7). Сбой chmod (каталог чужой) — предупреждение, хаб продолжает работу.""" diff --git a/src/jira_hub/release.py b/src/jira_hub/release.py new file mode 100644 index 0000000..8ce1569 --- /dev/null +++ b/src/jira_hub/release.py @@ -0,0 +1,104 @@ +"""Опубликованная версия агента для самообновления: <данные>/release/agent.py + manifest.json, без БД. +Спека hub-api «Публикация версии агента», design agent-self-update решения 1–2, 5.""" +import hashlib +import json +import logging +import os +import re +from datetime import datetime, timezone +from pathlib import Path + +from .paths import secure_dir + +log = logging.getLogger(__name__) + +MAX_SIZE = 1024 * 1024 +VERSION_RE = re.compile(r"^AGENT_VERSION = (\d+)[ \t]*\r?$", re.M) + + +class PublishError(Exception): + pass + + +def parse_version(text: str) -> int: + found = VERSION_RE.findall(text) + if len(found) != 1: + raise PublishError(f"в файле должна быть ровно одна строка AGENT_VERSION = <целое>, найдено {len(found)}") + return int(found[0]) + + +def _write_atomic(path: Path, data: bytes) -> None: + tmp = path.with_name("." + path.name + ".tmp") + tmp.write_bytes(data) + os.replace(tmp, path) + + +def publish(release_dir, source) -> dict: + """Опубликовать файл агента: только вверх; agent.py, затем манифест (по нему версия считается опубликованной).""" + src = Path(source) + try: + data = src.read_bytes() + except OSError as e: + raise PublishError(f"файл не прочитан {src}: {e}") from e + if len(data) > MAX_SIZE: + raise PublishError(f"файл больше 1 МБ ({len(data)} байт)") + try: + text = data.decode("utf-8") + except UnicodeDecodeError as e: + raise PublishError("файл не в UTF-8") from e + version = parse_version(text) + rd = Path(release_dir) + prev = current(rd) + if prev and version <= prev["version"]: + raise PublishError(f"опубликована v{prev['version']}, новая должна быть больше") + secure_dir(rd) + manifest = {"version": version, "sha256": hashlib.sha256(data).hexdigest(), "size": len(data), + "published_at": datetime.now(timezone.utc).replace(microsecond=0).isoformat()} + _write_atomic(rd / "agent.py", data) + _write_atomic(rd / "manifest.json", json.dumps(manifest, ensure_ascii=False).encode("utf-8")) + return manifest + + +def current(release_dir) -> dict | None: + """Опубликованная версия по манифесту; нет или битый манифест — None (агенту нечего обновлять).""" + path = Path(release_dir) / "manifest.json" + if not path.exists(): + return None + try: + m = json.loads(path.read_text(encoding="utf-8")) + return {"version": int(m["version"]), "sha256": str(m["sha256"]), "published_at": m.get("published_at")} + except (OSError, ValueError, KeyError, TypeError) as e: + log.warning("release: манифест %s не читается (%s) — публикации нет", path, e) + return None + + +def load(release_dir) -> dict | None: + """Файл агента для GET /agent/release; sha256 пересчитывается — несовпадение с манифестом = публикации нет.""" + m = current(release_dir) + if m is None: + return None + try: + data = (Path(release_dir) / "agent.py").read_bytes() + except OSError as e: + log.error("release: agent.py не прочитан: %s", e) + return None + if hashlib.sha256(data).hexdigest() != m["sha256"]: + log.error("release: agent.py не сходится с манифестом по sha256 — публикации нет") + return None + return {"version": m["version"], "sha256": m["sha256"], "content": data.decode("utf-8")} + + +def hint(rel, agent, update_state, launcher_version) -> str | None: + """Подсказка для agent_status (design решение 5).""" + try: + agent_version = int(agent.get("agent_version") or 0) + except (TypeError, ValueError): + agent_version = 0 + if rel and agent.get("online") and agent_version and rel["version"] > agent_version and launcher_version is None: + return "агент запущен без run.py — обновится только вручную" + us = update_state or {} + if us.get("state") == "rolled_back": + return f"откат с v{us.get('to_version')} на v{us.get('from_version')}: {us.get('error') or '—'}" + if us.get("state") == "rejected": + return f"v{us.get('to_version')} отвергнута: {us.get('error') or '—'}" + return None diff --git a/tests/test_cli.py b/tests/test_cli.py index d1733a9..ae2e938 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -23,3 +23,15 @@ def test_init_import_tokens(tmp_path, monkeypatch, capsys): assert cli.main(["tokens"]) == 0 assert settings.tokens(conn)["client"] in capsys.readouterr().out conn.close() + + +def test_agent_publish(tmp_path, monkeypatch, capsys): + monkeypatch.setenv("JIRA_HUB_HOME", str(tmp_path / "home")) + src = tmp_path / "agent.py" + src.write_text("AGENT_VERSION = 6\n", encoding="utf-8") + assert cli.main(["agent", "publish", str(src)]) == 0 + out = capsys.readouterr().out + assert out.startswith("опубликована v6, sha256 ") and "…, 18 байт" in out + assert (paths.release_dir() / "manifest.json").exists() + assert cli.main(["agent", "publish", str(src)]) == 1 + assert "опубликована v6, новая должна быть больше" in capsys.readouterr().err diff --git a/tests/test_release.py b/tests/test_release.py new file mode 100644 index 0000000..041445e --- /dev/null +++ b/tests/test_release.py @@ -0,0 +1,103 @@ +import json +import os +import stat + +import pytest + +from jira_hub import release + + +def agent_file(tmp_path, version, body="print('x')\n", name="agent.py"): + p = tmp_path / name + p.write_text(f'#!/usr/bin/env python3\n"""агент"""\nAGENT_VERSION = {version}\n{body}', encoding="utf-8") + return p + + +def test_publish_writes_file_and_manifest(tmp_path): + rd = tmp_path / "release" + src = agent_file(tmp_path, 6) + m = release.publish(rd, src) + assert m["version"] == 6 and m["size"] == src.stat().st_size and len(m["sha256"]) == 64 + assert (rd / "agent.py").read_bytes() == src.read_bytes() + assert json.loads((rd / "manifest.json").read_text(encoding="utf-8")) == m + assert stat.S_IMODE(os.stat(rd).st_mode) == 0o700 + assert release.current(rd) == {"version": 6, "sha256": m["sha256"], "published_at": m["published_at"]} + loaded = release.load(rd) + assert loaded == {"version": 6, "sha256": m["sha256"], "content": src.read_text(encoding="utf-8")} + assert not [p.name for p in rd.iterdir() if p.name.startswith(".")] # временных файлов не осталось + + +def test_publish_only_up(tmp_path): + rd = tmp_path / "release" + release.publish(rd, agent_file(tmp_path, 6)) + before = (rd / "manifest.json").read_bytes() + with pytest.raises(release.PublishError, match="опубликована v6, новая должна быть больше"): + release.publish(rd, agent_file(tmp_path, 6, body="print('y')\n")) + with pytest.raises(release.PublishError, match="опубликована v6"): + release.publish(rd, agent_file(tmp_path, 5)) + assert (rd / "manifest.json").read_bytes() == before + assert release.publish(rd, agent_file(tmp_path, 7))["version"] == 7 + + +@pytest.mark.parametrize("text", ["print(1)\n", "AGENT_VERSION = 5\nAGENT_VERSION = 6\n", "AGENT_VERSION = 'v5'\n", + " AGENT_VERSION = 5\n"]) +def test_version_line_required_once(tmp_path, text): + p = tmp_path / "a.py" + p.write_text(text, encoding="utf-8") + with pytest.raises(release.PublishError, match="AGENT_VERSION"): + release.publish(tmp_path / "release", p) + assert not (tmp_path / "release" / "manifest.json").exists() + + +def test_publish_rejects_non_utf8_and_big(tmp_path): + p = tmp_path / "a.py" + p.write_bytes(b"AGENT_VERSION = 5\n\xff\xfe") + with pytest.raises(release.PublishError, match="UTF-8"): + release.publish(tmp_path / "release", p) + p.write_bytes(b"AGENT_VERSION = 5\n" + b"#" * (1024 * 1024)) + with pytest.raises(release.PublishError, match="1 МБ"): + release.publish(tmp_path / "release", p) + with pytest.raises(release.PublishError, match="не прочитан"): + release.publish(tmp_path / "release", tmp_path / "нет.py") + + +def test_current_none_without_release(tmp_path): + assert release.current(tmp_path / "release") is None + assert release.load(tmp_path / "release") is None + + +def test_current_broken_manifest(tmp_path): + rd = tmp_path / "release" + rd.mkdir() + (rd / "manifest.json").write_text('{"version": 6, "sha', encoding="utf-8") + assert release.current(rd) is None + (rd / "manifest.json").write_text('{"version": "шесть", "sha256": "x"}', encoding="utf-8") + assert release.current(rd) is None + + +def test_load_rejects_sha_mismatch(tmp_path): + rd = tmp_path / "release" + release.publish(rd, agent_file(tmp_path, 6)) + (rd / "agent.py").write_text("AGENT_VERSION = 6\nподменён\n", encoding="utf-8") + assert release.current(rd)["version"] == 6 + assert release.load(rd) is None + + +def test_hint(): + rel = {"version": 6, "sha256": "s", "published_at": "t"} + online5 = {"agent_version": "5", "online": True} + assert release.hint(rel, online5, None, None) == "агент запущен без run.py — обновится только вручную" + assert release.hint(rel, online5, None, 1) is None + assert release.hint(rel, {"agent_version": "5", "online": False}, None, None) is None + assert release.hint(rel, {"agent_version": "6", "online": True}, None, None) is None + assert release.hint(None, online5, None, None) is None + rb = {"state": "rolled_back", "from_version": 6, "to_version": 7, "error": "код 1 через 3 с"} + assert release.hint(rel, {"agent_version": "6", "online": True}, rb, 1) == "откат с v7 на v6: код 1 через 3 с" + rj = {"state": "rejected", "from_version": 5, "to_version": 6, "error": "sha256 не сходится"} + assert release.hint(rel, online5, rj, 1) == "v6 отвергнута: sha256 не сходится" + assert release.hint(rel, online5, {"state": "ok", "from_version": 5, "to_version": 6}, 1) is None + + +def test_hint_none_when_no_agent(): + rel = {"version": 6, "sha256": "s", "published_at": "t"} + assert release.hint(rel, {"agent_version": None, "online": False}, None, None) is None -- 2.47.2 From 87a0d3d36cae66f0a10b79903fd6eda6be1ccd68 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 14 Oct 2026 22:11:10 +0400 Subject: [PATCH 4/7] =?UTF-8?q?feat(hub):=20agent=5Frelease=20=D0=B2=20cla?= =?UTF-8?q?im,=20GET=20/agent/release,=20=D1=81=D0=BE=D1=81=D1=82=D0=BE?= =?UTF-8?q?=D1=8F=D0=BD=D0=B8=D0=B5=20=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B2=20GET=20/agent=20(#22)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/jira_hub/api.py | 37 +++++++++++++++++++++--- tests/test_api.py | 70 ++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 102 insertions(+), 5 deletions(-) diff --git a/src/jira_hub/api.py b/src/jira_hub/api.py index ce5c2b2..bc70509 100644 --- a/src/jira_hub/api.py +++ b/src/jira_hub/api.py @@ -13,7 +13,7 @@ from pathlib import Path from typing import NamedTuple from urllib.parse import parse_qs, urlparse -from . import backup, db, mirror, queue, relay, settings +from . import backup, db, mirror, queue, relay, release, settings from .commands import CommandError from .paths import backups_dir as default_backups_dir from .paths import secure_dir @@ -39,9 +39,11 @@ class ApiError(Exception): class Hub: - def __init__(self, db_path, backups_dir=None): + def __init__(self, db_path, backups_dir=None, release_dir=None): self.db_path = Path(db_path) self.backups_dir = Path(backups_dir) if backups_dir else default_backups_dir() + self.release_dir = Path(release_dir) if release_dir else self.db_path.parent / "release" + self.agent_update = {"update_state": None, "launcher_version": None} # последнее из claim; в памяти (R-3) self.claim_wait = 30 secure_dir(self.backups_dir) # каталог копий закрыт при каждом старте, а не только после первой копии conn = self.conn() @@ -63,6 +65,18 @@ class Hub: if conn: conn.close() + def note_agent_update(self, body): + """update_state и launcher_version из claim: хранятся в памяти, агент шлёт их в каждом claim (R-3). + Значение неверного типа игнорируется — claim из-за него не отвергается.""" + us = body.get("update_state") + if us is None or isinstance(us, dict): + if us is not None and isinstance(us.get("error"), str): + us = {**us, "error": us["error"][:500]} + self.agent_update["update_state"] = us + lv = body.get("launcher_version") + if lv is None or (isinstance(lv, int) and not isinstance(lv, bool)): + self.agent_update["launcher_version"] = lv + def make_backup(self, label): conn = self.conn() try: @@ -91,6 +105,7 @@ def _not_found(obj, what): def h_claim(hub, conn, m, q, body): + hub.note_agent_update(body) deadline = time.monotonic() + hub.claim_wait while True: cmd = queue.claim(conn, body.get("agent_version"), body.get("settings_version"), body.get("last_jira_call_at"), @@ -98,7 +113,16 @@ def h_claim(hub, conn, m, q, body): if cmd or time.monotonic() >= deadline: break time.sleep(1) - return 200, {"command": cmd, "settings_version": settings.get(conn, "settings_version")} + rel = release.current(hub.release_dir) + return 200, {"command": cmd, "settings_version": settings.get(conn, "settings_version"), + "agent_release": {"version": rel["version"], "sha256": rel["sha256"]} if rel else None} + + +def h_agent_release(hub, conn, m, q, body): + rel = release.load(hub.release_dir) + if rel is None: + raise ApiError(404, "no_release", "версия агента не опубликована") + return 200, rel def h_agent_settings(hub, conn, m, q, body): @@ -231,7 +255,11 @@ def h_refresh(hub, conn, m, q, body): def h_agent_get(hub, conn, m, q, body): - return 200, queue.agent_view(conn) + view = queue.agent_view(conn) + rel = release.current(hub.release_dir) + us, lv = hub.agent_update["update_state"], hub.agent_update["launcher_version"] + view.update(release=rel, update_state=us, launcher_version=lv, update_hint=release.hint(rel, view, us, lv)) + return 200, view def h_sync_runs(hub, conn, m, q, body): @@ -269,6 +297,7 @@ ROUTES = [ ("POST", r"^/agent/commands/(\d+)/result$", h_agent_result, "agent"), ("POST", r"^/agent/state$", h_agent_state, "agent"), ("GET", r"^/agent/known-keys$", h_known_keys, "agent"), + ("GET", r"^/agent/release$", h_agent_release, "agent"), ("GET", r"^/issues$", h_issues, "client"), ("GET", r"^/issues/([^/]+)$", h_issue, "client"), ("GET", r"^/board$", h_board, "client"), diff --git a/tests/test_api.py b/tests/test_api.py index cfd873d..5459c72 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -5,7 +5,7 @@ import urllib.request import pytest -from jira_hub import api, db, settings +from jira_hub import api, db, release, settings from jira_hub.client import HubClient, HubError @@ -394,3 +394,71 @@ def test_page_reads_hub_and_has_no_embedded_export(): assert "Нет в зеркале" in html assert 'author: "viewer"' in html and "/batches/" in html assert "KEY_RE.test(l.source) && KEY_RE.test(l.target)" in html # I-1: ключи связей из зеркала идут в разметку карточек + + +def _publish(hub, tmp_path, version): + src = tmp_path / f"agent{version}.py" + src.write_text(f"AGENT_VERSION = {version}\nprint('v{version}')\n", encoding="utf-8") + return release.publish(hub["hub"].release_dir, src) + + +def test_claim_release_field(hub, tmp_path): + a = hub["agent"] + assert a.post("/agent/claim", {"agent_version": 5})["agent_release"] is None + m = _publish(hub, tmp_path, 6) + r = a.post("/agent/claim", {"agent_version": 5}) + assert r["command"] is None and r["agent_release"] == {"version": 6, "sha256": m["sha256"]} + + +def test_agent_release_route(hub, tmp_path): + with pytest.raises(HubError) as e: + hub["agent"].get("/agent/release") + assert e.value.status == 404 and e.value.error == "no_release" + m = _publish(hub, tmp_path, 6) + r = hub["agent"].get("/agent/release") + assert r == {"version": 6, "sha256": m["sha256"], "content": "AGENT_VERSION = 6\nprint('v6')\n"} + with pytest.raises(HubError) as e: + hub["client"].get("/agent/release") + assert e.value.status == 401 + + +def test_claim_update_state_shown_in_agent(hub, tmp_path): + _publish(hub, tmp_path, 7) + us = {"state": "rolled_back", "from_version": 6, "to_version": 7, "sha256": "s", "error": "код 1 через 3 с" + "x" * 600, + "at": "2026-10-14T20:00:00+00:00"} + hub["agent"].post("/agent/claim", {"agent_version": 6, "update_state": us, "launcher_version": 1}) + v = hub["client"].get("/agent") + assert v["release"]["version"] == 7 and v["release"]["published_at"] + assert v["launcher_version"] == 1 + assert v["update_state"]["state"] == "rolled_back" and len(v["update_state"]["error"]) == 500 + assert v["update_hint"].startswith("откат с v7 на v6: код 1 через 3 с") + + +def test_claim_without_update_fields(hub, tmp_path): + hub["agent"].post("/agent/claim", {"agent_version": 6, "update_state": {"state": "ok", "from_version": 5, + "to_version": 6}, "launcher_version": 1}) + hub["agent"].post("/agent/claim", {"agent_version": 4}) # агент v4: полей нет + v = hub["client"].get("/agent") + assert v["launcher_version"] is None and v["update_state"] is None + + +def test_claim_bad_update_types_ignored(hub): + a = hub["agent"] + a.post("/agent/claim", {"agent_version": 5, "update_state": {"state": "ok"}, "launcher_version": 1}) + r = a.post("/agent/claim", {"agent_version": 5, "update_state": "ok", "launcher_version": "1"}) + assert "command" in r + a.post("/agent/claim", {"agent_version": 5, "update_state": {"state": "ok"}, "launcher_version": True}) + v = hub["client"].get("/agent") + assert v["update_state"] == {"state": "ok"} and v["launcher_version"] == 1 + + +def test_hint_without_launcher(hub, tmp_path): + _publish(hub, tmp_path, 6) + hub["agent"].post("/agent/claim", {"agent_version": 5}) + v = hub["client"].get("/agent") + assert v["online"] is True and v["update_hint"] == "агент запущен без run.py — обновится только вручную" + + +def test_agent_view_without_release(hub): + v = hub["client"].get("/agent") + assert v["release"] is None and v["update_state"] is None and v["launcher_version"] is None and v["update_hint"] is None -- 2.47.2 From 7c922382e645824f543b84847c9cba4d689d43a8 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 14 Oct 2026 22:13:21 +0400 Subject: [PATCH 5/7] =?UTF-8?q?feat(hub):=20agent=5Fstatus=20=E2=80=94=20?= =?UTF-8?q?=D0=BF=D1=83=D0=B1=D0=BB=D0=B8=D0=BA=D0=B0=D1=86=D0=B8=D1=8F=20?= =?UTF-8?q?=D0=B8=20=D1=81=D0=B0=D0=BC=D0=BE=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B0=D0=B3=D0=B5=D0=BD=D1=82?= =?UTF-8?q?=D0=B0;=20README,=20ARCHITECTURE=20(#22)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- ARCHITECTURE.md | 6 ++++-- CLAUDE.md | 5 +++-- README.md | 21 +++++++++++++++++++-- src/jira_hub/mcp_server.py | 12 +++++++++++- tests/test_mcp.py | 13 +++++++++++++ 5 files changed, 50 insertions(+), 7 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 2065de9..f3fa56c 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -13,12 +13,13 @@ | `commands` | контракт типов команд, валидация, временные ключи, оценка, подтверждение по состоянию | `mirror` | | `queue` | пакеты, выдача (`claim`), отчёты, отмена, повтор, автообновление | `commands`, `mirror`, `settings`, `db` | | `backup` | копии файла базы, ротация | `paths` | -| `api` | HTTP-сервер, маршруты, авторизация по токенам, фоновый `tick`; страница доски GET /, /links (файл web/links.html, без токена) и снимок GET /board | `queue`, `mirror`, `settings`, `backup`, `relay` | +| `api` | HTTP-сервер, маршруты, авторизация по токенам, фоновый `tick`; страница доски GET /, /links (файл web/links.html, без токена) и снимок GET /board | `queue`, `mirror`, `settings`, `backup`, `relay`, `release` | | `relay` | реле общей папки (настройка `relay_dir`): поток в `serve`, файлы `req/` → свой HTTP (адрес `serve`, для `0.0.0.0` — `127.0.0.1`) → `resp/`; агент с `hub_url = folder:<путь>` ходит через него; HTTP-обработчики о нём не знают | `settings` | +| `release` | опубликованная версия агента: `release/agent.py` + `manifest.json` в каталоге данных, `publish` (только вверх), `current`, `load` (с проверкой sha256), подсказка `hint` для `agent_status` | `paths` | | `client` | HTTP-клиент хаба | `db`, `paths`, `settings` (только для `from_home`) | | `mcp_server` | инструменты MCP по stdio | `client` | | `importer` | разовый импорт CSV-пакета в заметки, сверка с зеркалом | `mirror`, `settings` | -| `cli` | `init`, `serve`, `import`, `mcp`, `tokens` | `api`, `mcp_server`, `importer`, `settings`, `db`, `paths` | +| `cli` | `init`, `serve`, `import`, `mcp`, `tokens`, `agent publish` | `api`, `mcp_server`, `importer`, `release`, `settings`, `db`, `paths` | `src/jira_hub/web/links.html` — страница доски связей: один HTML без зависимостей, хаб отдаёт его как есть. Данные страница берёт из `GET /board` с клиентским токеном и переводит в модель доски сама (вид работы по меткам, имена @@ -31,6 +32,7 @@ - Запись в Jira: клиент (`mcp_server`/интерфейс) → `POST /batches` → `queue` → агент `claim` → Jira → `POST /agent/commands/{id}/result`. - Чтение Jira: команда `refresh` → агент → `POST /agent/state` → `mirror.apply_state`. - Агент за VPN: агент → файлы в общей папке (`relay_dir`) → `relay` → HTTP хаба → ответ файлом. +- Самообновление агента: `jira-hub agent publish` → `release/` → `claim.agent_release` → агент скачивает `GET /agent/release`; состояние обновления агент шлёт в `claim`, хаб держит в памяти и отдаёт в `GET /agent`. - Токены: агентский — только `/agent/*`, клиентский — остальное; MCP берёт клиентский токен из базы через `HubClient.from_home`. ## Границы diff --git a/CLAUDE.md b/CLAUDE.md index 935f609..eec637d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,14 +1,14 @@ # jira-hub ## Что это -Хаб моста Jira для АРМ КА: единственный источник истины по задачам проекта AURORA (метка AWPCA) на Mac mini (`192.168.0.106`). Хранит зеркало задач, служебные заметки и очередь команд в SQLite, отдаёт HTTP API в локальной сети (агенту на виртуалке `jira-agent` и интерфейсам) и MCP-сервер для Claude. Главный сценарий: из чата поставить пакет команд, агент на виртуалке выполняет их в Jira по правилу «проверь, потом сделай» и возвращает состояние задач в зеркало. Спеки: `openspec/specs/`; текущего change нет (последний — `openspec/changes/archive/2026-10-14-viewer-live/`). +Хаб моста Jira для АРМ КА: единственный источник истины по задачам проекта AURORA (метка AWPCA) на Mac mini (`192.168.0.106`). Хранит зеркало задач, служебные заметки и очередь команд в SQLite, отдаёт HTTP API в локальной сети (агенту на виртуалке `jira-agent` и интерфейсам) и MCP-сервер для Claude. Главный сценарий: из чата поставить пакет команд, агент на виртуалке выполняет их в Jira по правилу «проверь, потом сделай» и возвращает состояние задач в зеркало. Спеки: `openspec/specs/`; текущий change — `openspec/changes/agent-self-update/` (самообновление агента). ## Стек и ограничения - Python 3.13, uv, hatchling, pytest; HTTP на stdlib (`http.server`), БД — SQLite (stdlib), MCP — пакет `mcp` (закреплён `<2`, рулинг R-10). - Хаб не знает пароль Jira и не ходит в Jira: учётные данные и сеть до Jira только у агента. - Зеркало задач пишет только `mirror.apply_state` по загрузке состояния от агента (`POST /agent/state`); правки идут командами через очередь. - Хаб не создаёт команды сам: повтор после failed — новая команда по запросу человека/интерфейса. Команду, выданную агенту и оставшуюся без отчёта, хаб довыдаёт при следующем claim (до 3 выдач, затем failed). -- Данные: `~/.local/share/jira-hub/` (`JIRA_HUB_HOME`): `hub.sqlite`, `backups/`; каталог данных и `backups/` — права 0700. Порт по умолчанию 8790. +- Данные: `~/.local/share/jira-hub/` (`JIRA_HUB_HOME`): `hub.sqlite`, `backups/`, `release/` (опубликованная версия агента); каталог данных, `backups/` и `release/` — права 0700. Порт по умолчанию 8790. - Два статических токена (в таблице `setting`): агентский открывает только `/agent/*`, клиентский — остальное. ## Архитектура @@ -24,6 +24,7 @@ - `uv run jira-hub serve [--host] [--port]` — локальный запуск хаба. - Доска связей — `http://127.0.0.1:8790/` (страница `src/jira_hub/web/links.html`, вход по клиентскому токену). - `uv run jira-hub import FOLDER` — импорт CSV-пакета `admin/jira-sync`; `uv run jira-hub mcp` — MCP по stdio; `uv run jira-hub tokens` — показать токены. +- `uv run jira-hub agent publish PATH` — опубликовать версию агента для самообновления (только вверх). ## Тесты - Golden-тесты: `tests/fixtures/.*` + эталон `.expected.*`. Внешние сервисы в тестах мокаются. diff --git a/README.md b/README.md index c2e7fa3..0f7bfe7 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,22 @@ curl -s -X PUT -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/j жду» при подключённом SSD) — macOS не пускает процесс на внешний диск: «Системные настройки → Конфиденциальность и безопасность → Полный доступ к диску» → добавить `/opt/homebrew/bin/uv` и python из `.venv` хаба, перезапустить задание. +## Самообновление агента + +Агент v5+ под запускателем `run.py` обновляется сам (спека `openspec/specs/agent/`, «Самообновление агента»). +Опубликовать новую версию: + +```bash +uv run jira-hub agent publish ../jira-agent/src/agent.py +``` + +Версия берётся из строки `AGENT_VERSION = N` и должна быть больше опубликованной; файл и `manifest.json` ложатся в +`~/.local/share/jira-hub/release/` (0700). Перезапуск хаба не нужен: версия уходит агенту в ответе `claim` +(`agent_release`), файл — по `GET /agent/release`. Агент ставит её в паузе между командами; через несколько минут +`agent_status` (MCP) покажет «обновление: ok v<старая> → v<новая>». Если новая версия упала или не вышла на связь, +запускатель вернёт прежнюю — `agent_status` покажет «откат с v …»; исправление публикуется следующим номером. +Снять публикацию нельзя: откат «назад» — тот же старый код с новым номером. + ## HTTP API Все маршруты, кроме страницы доски (`GET /`, `GET /links`), требуют `Authorization: Bearer <токен>`; ответы JSON, @@ -103,12 +119,13 @@ curl -s -X PUT -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/j | Маршрут | Токен | Назначение | |---|---|---| -| `POST /agent/claim` | агент | взять следующую команду (долгий запрос до 30 с) | +| `POST /agent/claim` | агент | взять следующую команду (долгий запрос до 30 с); в теле — `update_state`, `launcher_version`; в ответе — `agent_release` (опубликованная версия агента или `null`) | | `GET /agent/settings` | агент | настройки для агента (без токенов) | | `GET /agent/commands/{id}` | агент | команда по id | | `POST /agent/commands/{id}/result` | агент | отчёт о выполнении (идемпотентно) | | `POST /agent/state` | агент | загрузка состояния задач пачками | | `GET /agent/known-keys` | агент | ключи зеркала | +| `GET /agent/release` | агент | опубликованная версия агента `{version, sha256, content}`; 404 `no_release` | | `GET /issues` | клиент | фильтры `sprint`, `assignee`, `work_kind`, `fact_status`, `status`, `label`, `epic`, `neighbor`, `q`, `limit`, `offset` | | `GET /issues/{key}` | клиент | задача целиком | | `GET /board` | клиент | снимок для доски: все задачи с `fact_status`/`work_kind`, все связи, `last_sync` — последний успешный полный прогон | @@ -118,7 +135,7 @@ curl -s -X PUT -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/j | `POST /batches/{id}/cancel` | клиент | отмена пакета | | `POST /commands/{id}/retry`, `POST /commands/{id}/cancel` | клиент | повтор (новый пакет) / отмена (`force` для взятой) | | `POST /refresh` | клиент | `{keys}`, `{jql}` или `{full: true}` | -| `GET /agent`, `GET /sync-runs` | клиент | состояние агента, журнал загрузок | +| `GET /agent`, `GET /sync-runs` | клиент | состояние агента (с `release`, `update_state`, `launcher_version`, `update_hint`), журнал загрузок | | `GET /settings`, `PUT /settings` | клиент | настройки (PUT частично, поднимает `settings_version`) | ```bash diff --git a/src/jira_hub/mcp_server.py b/src/jira_hub/mcp_server.py index bcf9a6d..02d93e4 100644 --- a/src/jira_hub/mcp_server.py +++ b/src/jira_hub/mcp_server.py @@ -106,6 +106,15 @@ def fmt_agent(a) -> str: out.append("внимание: " + a["warning"]) for s in a.get("redelivered", []): out.append(f"выдана повторно: #{s['id']} {s['type']}, выдач {s['deliveries']}, последняя {s['claimed_at']}") + rel = a.get("release") + out.append(f"публикация: v{rel['version']} от {_v(rel.get('published_at'))}" if rel else "публикации нет") + us = a.get("update_state") + if us: + out.append(f"обновление: {us.get('state')} v{_v(us.get('from_version'))} → v{_v(us.get('to_version'))}") + lv = a.get("launcher_version") + out.append(f"запускатель: v{lv}" if lv is not None else "без запускателя") + if a.get("update_hint"): + out.append(a["update_hint"]) return "\n".join(out) @@ -210,7 +219,8 @@ async def refresh(keys: list[str] | None = None, full: bool = False, wait: bool @mcp.tool() @guard def agent_status() -> str: - """Агент: онлайн/офлайн, версия, текущая команда, 429 за сутки, команды без ответа.""" + """Агент: онлайн/офлайн, версия, текущая команда, 429 за сутки, команды без ответа, опубликованная версия, + состояние самообновления и запускатель.""" return fmt_agent(_client().get("/agent")) diff --git a/tests/test_mcp.py b/tests/test_mcp.py index c7af1ef..26abec3 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -202,3 +202,16 @@ def test_fmt_batch_warns_about_claimed(): def test_batch_create_mentions_labels(): doc = mcp_server.batch_create.__doc__ or "" assert "labels" in doc and "AWPCA" in doc + + +def test_fmt_agent_update_lines(): + base = {"online": True, "agent_version": "6", "current_command_id": None, "rate_limited_24h": 0, "redelivered": [], + "warning": None, "last_seen_at": "2026-10-14T20:00:00+00:00", "settings_version": 3} + text = mcp_server.fmt_agent({**base, "release": {"version": 7, "sha256": "s", "published_at": "2026-10-14T19:00:00+00:00"}, + "update_state": {"state": "rolled_back", "from_version": 6, "to_version": 7}, + "launcher_version": 1, "update_hint": "откат с v7 на v6: код 1 через 3 с"}) + assert "публикация: v7 от 2026-10-14T19:00:00+00:00" in text + assert "обновление: rolled_back v6 → v7" in text + assert "запускатель: v1" in text and "откат с v7 на v6: код 1 через 3 с" in text + text = mcp_server.fmt_agent({**base, "release": None, "update_state": None, "launcher_version": None, "update_hint": None}) + assert "публикации нет" in text and "без запускателя" in text and "обновление:" not in text -- 2.47.2 From 3a4fed94ff80c5d4002052a34dfe724002ea2346 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 14 Oct 2026 22:35:33 +0400 Subject: [PATCH 6/7] =?UTF-8?q?fix(hub):=20=D0=BE=D1=88=D0=B8=D0=B1=D0=BA?= =?UTF-8?q?=D0=B0=20=D0=B4=D0=BE=D1=81=D1=82=D1=83=D0=BF=D0=B0=20=D0=BA=20?= =?UTF-8?q?release/=20=D0=BD=D0=B5=20=D1=80=D0=BE=D0=BD=D1=8F=D0=B5=D1=82?= =?UTF-8?q?=20claim=20=E2=80=94=20=D0=BF=D1=83=D0=B1=D0=BB=D0=B8=D0=BA?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D0=B8=20=D0=BD=D0=B5=D1=82=20(I-1,=20#22)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/jira_hub/api.py | 6 +++++- src/jira_hub/release.py | 6 +++--- tests/test_api.py | 20 ++++++++++++++++++++ 3 files changed, 28 insertions(+), 4 deletions(-) diff --git a/src/jira_hub/api.py b/src/jira_hub/api.py index bc70509..ac4729c 100644 --- a/src/jira_hub/api.py +++ b/src/jira_hub/api.py @@ -113,7 +113,11 @@ def h_claim(hub, conn, m, q, body): if cmd or time.monotonic() >= deadline: break time.sleep(1) - rel = release.current(hub.release_dir) + try: # команда уже выдана: публикация не должна превращать ответ в 500 (I-1) + rel = release.current(hub.release_dir) + except Exception: # noqa: BLE001 + log.exception("release: чтение публикации при claim") + rel = None return 200, {"command": cmd, "settings_version": settings.get(conn, "settings_version"), "agent_release": {"version": rel["version"], "sha256": rel["sha256"]} if rel else None} diff --git a/src/jira_hub/release.py b/src/jira_hub/release.py index 8ce1569..a0d093a 100644 --- a/src/jira_hub/release.py +++ b/src/jira_hub/release.py @@ -62,12 +62,12 @@ def publish(release_dir, source) -> dict: def current(release_dir) -> dict | None: """Опубликованная версия по манифесту; нет или битый манифест — None (агенту нечего обновлять).""" path = Path(release_dir) / "manifest.json" - if not path.exists(): - return None try: m = json.loads(path.read_text(encoding="utf-8")) return {"version": int(m["version"]), "sha256": str(m["sha256"]), "published_at": m.get("published_at")} - except (OSError, ValueError, KeyError, TypeError) as e: + except (FileNotFoundError, NotADirectoryError): + return None + except (OSError, ValueError, KeyError, TypeError) as e: # нет прав, битый JSON: публикации нет, мост работает log.warning("release: манифест %s не читается (%s) — публикации нет", path, e) return None diff --git a/tests/test_api.py b/tests/test_api.py index 5459c72..13c6a08 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,4 +1,5 @@ import json +import os import threading import urllib.error import urllib.request @@ -462,3 +463,22 @@ def test_hint_without_launcher(hub, tmp_path): def test_agent_view_without_release(hub): v = hub["client"].get("/agent") assert v["release"] is None and v["update_state"] is None and v["launcher_version"] is None and v["update_hint"] is None + + +def test_claim_survives_unreadable_release(hub): + rd = hub["hub"].release_dir + rd.mkdir() + (rd / "manifest.json").write_text('{"version": 6, "sha256": "x"}', encoding="utf-8") + os.chmod(rd, 0) + try: + b = hub["client"].post("/batches", {"title": "т", "author": "claude", + "commands": [{"type": "ensure_fields", "params": {"key": "A-1", "duedate": "2026-10-23"}}]}) + r = hub["agent"].post("/agent/claim", {"agent_version": 5}) + assert r["command"] is not None and r["agent_release"] is None # публикация не роняет выдачу команды + assert hub["client"].get("/agent")["release"] is None + with pytest.raises(HubError) as e: + hub["agent"].get("/agent/release") + assert e.value.status == 404 + assert b["id"] + finally: + os.chmod(rd, 0o700) -- 2.47.2 From ed9c32a739da394575a524ab6dbf9007d912e998 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 14 Oct 2026 22:38:59 +0400 Subject: [PATCH 7/7] =?UTF-8?q?docs(openspec):=20agent-self-update=20?= =?UTF-8?q?=E2=80=94=20=D1=81=D0=B8=D0=B3=D0=BD=D0=B0=D0=BB=D1=8B=20run.py?= =?UTF-8?q?=20=D0=B8=20=D0=B2=D1=8B=D1=85=D0=BE=D0=B4=200=20=D0=B1=D0=B5?= =?UTF-8?q?=D0=B7=20Ctrl-C=20(R-20),=20=D1=80=D1=83=D0=BB=D0=B8=D0=BD?= =?UTF-8?q?=D0=B3=D0=B8=20R-19=E2=80=A6R-21=20(#22)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- openspec/changes/agent-self-update/design.md | 10 ++++++++-- .../agent-self-update/specs/agent/spec.md | 16 ++++++++++++---- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/openspec/changes/agent-self-update/design.md b/openspec/changes/agent-self-update/design.md index bfd88b6..fdad7a5 100644 --- a/openspec/changes/agent-self-update/design.md +++ b/openspec/changes/agent-self-update/design.md @@ -110,11 +110,14 @@ 15. **Дочерний процесс.** `[sys.executable, "-u", <каталог>/agent.py, "run", *аргументы]` с переменной окружения `JIRA_AGENT_LAUNCHER=`; stdout/stderr наследуются (лог агента и запускателя — в одном месте). Агент передаёт `launcher_version` в `claim`. Строки запускателя — с префиксом «run:» и временем, как у агента. -16. **Сигналы.** SIGINT запускатель игнорирует (Ctrl-C в терминале и так приходит дочернему процессу — общая группа). +16. **Сигналы.** SIGINT при живом агенте запускатель только запоминает как «остановил человек» (Ctrl-C в терминале и так + приходит дочернему процессу — общая группа); без агента (пауза перед перезапуском) — выход. После сигнала остановки + новый агент не запускается. SIGTERM (`kill`, `launchctl unload`) пересылается дочернему процессу; повторный SIGTERM — пересылается снова (агент выходит немедленно, 130). После выхода дочернего процесса по сигналу запускатель выходит. 17. **Коды выхода дочернего процесса:** - - `0` или `130` — остановка человеком: запускатель выходит с тем же кодом; + - `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}`, запуск новой версии. @@ -192,3 +195,6 @@ | 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` обновляется вручную, падение запускателя останавливает мост до человека | нет | diff --git a/openspec/changes/agent-self-update/specs/agent/spec.md b/openspec/changes/agent-self-update/specs/agent/spec.md index 016327d..a1065db 100644 --- a/openspec/changes/agent-self-update/specs/agent/spec.md +++ b/openspec/changes/agent-self-update/specs/agent/spec.md @@ -88,14 +88,18 @@ README агента SHALL описывать: первую установку ( `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 — пересылать дочернему процессу. +неблокирующую блокировку `run.py.lock`; занята — «запускатель уже работает», код 1. SIGINT при живом дочернем процессе +запускатель SHALL только запоминать как остановку человеком (Ctrl-C получает и агент), без дочернего процесса (пауза +перед перезапуском) — завершаться; SIGTERM — пересылать дочернему процессу. После сигнала остановки запускатель MUST NOT +запускать агента снова. По коду выхода дочернего процесса: -- `0` или `130` — запускатель завершается с тем же кодом; +- `0`, `130` или завершение по SIGINT после Ctrl-C (или после SIGTERM запускателю) — запускатель завершается с тем же + кодом; те же коды без сигнала запускателю — падение; - `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` = `rejected` «файл изменился после проверки», запуск текущей версии; если перед этим был Ctrl-C — + после подмены запускатель завершается, пробный период начнётся при следующем старте; - иной код при `update.json` = `trial` — откат; иначе — перезапуск той же версии с паузой 5 с, удваиваемой до 300 с при каждом следующем падении и сбрасываемой после 10 минут работы. @@ -119,6 +123,10 @@ README агента SHALL описывать: первую установку ( - **THEN** запускатель посылает SIGTERM, через 60 с при необходимости SIGKILL, делает откат с причиной «не вышла на связь за 10 мин» +#### Scenario: Новая версия сама вышла с кодом 0 +- **WHEN** `update.json` = `trial` для v7, дочерний процесс вышел с кодом 0, сигналов запускатель не получал +- **THEN** откат на v6 с причиной «код 0 через с», как при падении + #### Scenario: Падение после подтверждения - **WHEN** `update.json` = `ok`, агент дважды подряд упал с кодом 1, проработав меньше минуты - **THEN** запускатель перезапускает ту же версию через 5 с, затем через 10 с; отката нет -- 2.47.2