From d427bf3584a33546b8688cc82fbf7debbf05ccd9 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 14 Oct 2026 23:52:00 +0400 Subject: [PATCH 1/3] =?UTF-8?q?docs(openspec):=20agent-v9-hardening=20?= =?UTF-8?q?=E2=80=94=20=D1=81=D0=BF=D0=B5=D0=BA=D0=B0=20=D0=B7=D0=B0=D0=BF?= =?UTF-8?q?=D1=83=D1=81=D0=BA=D0=B0=D1=82=D0=B5=D0=BB=D1=8F=20v2=20=D0=B8?= =?UTF-8?q?=20=D0=B0=D0=B3=D0=B5=D0=BD=D1=82=D0=B0=20v9=20(#26)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../changes/agent-v9-hardening/.openspec.yaml | 2 + openspec/changes/agent-v9-hardening/design.md | 148 +++++++++++ .../changes/agent-v9-hardening/proposal.md | 58 +++++ .../agent-v9-hardening/specs/agent/spec.md | 234 ++++++++++++++++++ 4 files changed, 442 insertions(+) create mode 100644 openspec/changes/agent-v9-hardening/.openspec.yaml create mode 100644 openspec/changes/agent-v9-hardening/design.md create mode 100644 openspec/changes/agent-v9-hardening/proposal.md create mode 100644 openspec/changes/agent-v9-hardening/specs/agent/spec.md diff --git a/openspec/changes/agent-v9-hardening/.openspec.yaml b/openspec/changes/agent-v9-hardening/.openspec.yaml new file mode 100644 index 0000000..49ea85b --- /dev/null +++ b/openspec/changes/agent-v9-hardening/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-14 diff --git a/openspec/changes/agent-v9-hardening/design.md b/openspec/changes/agent-v9-hardening/design.md new file mode 100644 index 0000000..5eed838 --- /dev/null +++ b/openspec/changes/agent-v9-hardening/design.md @@ -0,0 +1,148 @@ +## Context + +Агент v8 (= код v5) работает на виртуалке под `run.py` v1 (launchd, KeepAlive), транспорт — общая папка UTM. +Код: хаб `main` 3d13f2d, агент `main` d364cee. Issues: aep/jira-hub#26 (эта дельта спеки), aep/jira-agent#23 (код). +Источник находок: aep/jira-agent#22 (M-3…M-7, M-10 финального ревью `agent-self-update`), #18 (M-2, M-3, M-5, M-6 +пакета C), #4 (M-10 — точность фейков). + +Найдено при чтении кода: +- `run.py` решает «пробный период или нет» только по `update.json.state == "trial"` (`Launcher.run`), sha256 файла + `agent.py` не сверяет. `update.json.sha256` равен sha256 файла версии: агент пишет в `agent.py.new` ровно байты + `content`, sha256 которых проверен. +- `apply_update`: копия `agent.py` → `agent.py.prev.tmp` → `agent.py.prev`, затем `os.replace(new, agent)`, затем + `write_state(trial)`. Между двумя последними шагами `update.json = staged`, `agent.py.new` уже нет: при перезапуске + `recover_files` ничего не делает, новая версия работает без пробного периода, `confirm()` агента (только из `trial`) + `ok` не пишет — `staged` навсегда (M-5). +- `rollback`: `os.replace(prev, agent)`, затем `write_state(rolled_back)`. Падение между ними оставляет `trial` при + прежней версии в `agent.py` и без `agent.py.prev`: каждые 10 минут SIGTERM, «откат невозможен», перезапуск (M-3). +- `wait_child` после таймаута сразу возвращает «истёк»; ответ `claim`, пришедший за секунду до SIGTERM, успевает + записать `ok`, но откат всё равно делается (M-4). +- `cmd_run` при занятой блокировке журнала возвращает 1 — как любое падение; в `trial` это откат здоровой версии, + если журнал держит осиротевший агент (M-7). +- `Updater.prepare` пишет `agent.py.new.tmp`, делает `chmod`, `os.replace`, запускает `selftest` — `OSError` + (диск полон, нет прав) не ловится: трейсбек, код 1, перезапуск, снова `prepare` (M-6). В `run.py` `OSError` в + `apply_update` роняет запускатель; launchd поднимает его, `recover_files` снова вызывает `apply_update` — цикл. +- `Updater.check` делает `str(release.get("sha256"))`: `None` превращается в `"None"`, подготовка каждый раз получает + «публикация сменилась между запросами» — временный сбой каждые 5 минут навсегда (M-10). Хаб сам такого не отдаёт + (`release.current` возвращает `str(m["sha256"])`), но агент не должен зависеть от этого. +- `restore_pace` при времени журнала в будущем пишет предупреждение и затем «последний вызов Jira не позже <будущее>» + (M-2 #18). +- `do_refresh` вызывает `hub.known_keys()` и `hub.state()`; их `ConnectionError` уходит в `Executor.run` как обрыв + связи с Jira: `retryable`, лог «обрыв связи с Jira», итог «результат неизвестен (обрыв связи с Jira)…: не ответил + через папку …» (M-6 #18). +- Фейки: уже как в живой Jira — 400 на `key in (...)` с несуществующим ключом (`fake_jira.py:154`), ответ agile без + `fields` у задачи без спринта (`:251`), `PUT` с `null` очищает поле. Не как в Jira: поиск дубля + (`reporter = currentUser()`) не учитывает `project =` и `created >= -7d`; `fake_hub` отвечает на `claim` сразу, а + настоящий хаб держит его до 30 с при пустой очереди (`api.h_claim`, `claim_wait = 30`). + +## Goals / Non-Goals + +**Goals:** запускатель не откатывает здоровую версию и не оставляет новую без пробного периода ни при каком месте +падения; нехватка места не роняет ни агента, ни запускатель; неверная публикация не даёт бесконечных сбоев; тексты +ошибок называют настоящий источник; фейки повторяют наблюдённое поведение Jira и хаба там, где агент от него зависит. + +**Non-Goals:** см. proposal. + +## Decisions + +### Запускатель `run.py` v2 + +1. **Сверка состояния (`reconcile`)** при старте вместо `recover_files`. Сначала удаляются `agent.py.new.tmp`, + `agent.py.prev.tmp`, `update.json.tmp`; если `agent.py` нет, а `agent.py.prev` есть — восстановление, как сейчас. + Затем по `update.json` (`S` — его `sha256`, `A` — sha256 файла `agent.py`): + + | `state` | `A` | `agent.py.new` | Действие | + |---|---|---|---| + | `staged` | `= S` | любое | падение после подмены (M-5): `agent.py.new` удалить, записать `trial` | + | `staged` | `≠ S` | есть, sha256 `= S` | подмена (решение 4) | + | `staged` | `≠ S` | нет или sha256 `≠ S` | `agent.py.new` удалить, `rejected` «подготовленный файл потерян» | + | `trial` | `= S` | — | без изменений (пробный период начнётся при запуске) | + | `trial` | `≠ S` | — | устаревший пробный период (M-3): `rolled_back` «пробный период прерван: agent.py не v»; отката файла нет | + | иное или нет файла | — | есть | `agent.py.new` удалить | + + Запись `rolled_back` (а не `ok`) при устаревшем `trial`: агент эту версию больше не ставит — так и нужно, если + прерван откат; если `agent.py` подменили руками, вернуть версию можно публикацией с номером выше (решение владельца). +2. **Пробный период перед каждым запуском** включается, только если `update.json.state = trial` и sha256 `agent.py` + равен `update.json.sha256`. Иначе — та же сверка, что при старте (строка `trial`/`≠ S`). sha256 считается на каждом + запуске агента: файл ~60 КБ, запуски редки. +3. **Повторная проверка `ok` (M-4).** После истечения 10 минут и остановки агента (SIGTERM, через 60 с SIGKILL) + запускатель перечитывает `update.json`: `ok` — отката нет, лог «подтверждение пришло в последний момент», та же + версия запускается без паузы; иначе — откат, как сейчас. +4. **Подмена с `OSError` (M-6).** Шаги подмены прежние. `OSError` до `os.replace(new, agent)` — удалить + `agent.py.prev.tmp` и `agent.py.new`, записать `rejected` «подмена не удалась: <ошибка>», запустить текущий + `agent.py`. `OSError` при записи `trial` после замены — немедленно вернуть `agent.py.prev` на место (переименование, + места не требует), попытаться записать `rejected`; если не удалось — при следующем старте сверка увидит + `staged` / `≠ S` / нет `agent.py.new` → `rejected`. `rejected` навсегда для этой sha256, повтора нет: у запускателя + нет таймера, повтор ушёл бы в цикл; лечится освобождением места и публикацией v+1 (решение владельца). +5. **Запись `update.json` в запускателе** не роняет его: `OSError` — лог «не удалось записать update.json: …», + работа продолжается. Откат (`os.replace(prev, agent)`) места не требует. +6. **Код 73 (M-7).** Выход агента с 73 — «журнал занят другим процессом»: не сбой версии. Отката нет даже в `trial`, + лог «агент уже запущен другим процессом (журнал занят), повтор через N с», перезапуск с обычной нарастающей паузой + 5…300 с. Чужие процессы запускатель не трогает. В `trial` пробный период начинается заново при каждом запуске. +7. `LAUNCHER_VERSION = 2`; переменная `JIRA_AGENT_LAUNCHER = 2` уходит хабу в `claim` как `launcher_version`. + +### Агент v9 + +8. **Код 73.** `cmd_run` при занятой блокировке журнала возвращает `EXIT_LOCKED = 73` (`EX_CANTCREAT`, рядом с 75 + `EX_TEMPFAIL`) с прежним текстом «агент уже запущен…». `prune` при занятой блокировке — по-прежнему 1: его + запускатель не вызывает. +9. **`OSError` в подготовке.** В `Updater.prepare` запись `agent.py.new.tmp`, `os.replace`, запуск `selftest` и запись + `staged` обёрнуты: `OSError` — временный сбой (`_transient`: удалить `agent.py.new` и `.tmp`, повтор не раньше 5 мин), + лог «обновление до v отложено на 5 мин: файлы: <ошибка>». `_drop_new` терпит любой `OSError`. Запись `rejected` + с `OSError` — только лог: агент работает дальше. +10. **Формат `agent_release`.** `Updater.check` принимает публикацию, только если `version` — целое (не `bool`) и + `sha256` — строка из 64 символов `[0-9a-f]`. Иначе — `False` и лог «хаб прислал неполную публикацию» не чаще раза + в час (тот же таймер, что у подсказки про `run.py`). +11. **Время журнала в будущем (M-2 #18).** `restore_pace` пишет одну строку «журнал: время последней записи в будущем + (<время>) — пауза перед первой командой полная» и не пишет вторую. +12. **Источник ошибки в `refresh` (M-6 #18).** Вызовы хаба в `do_refresh` (`known_keys`, `state`) оборачиваются: + `ConnectionError` и `HttpError` хаба превращаются в `HubError` с текстом «хаб: <исходный текст>». `Executor.run` + отдаёт по ней `failed` с этим текстом и признаком источника «хаб»; `retryable` — для `ConnectionError` и + `HttpError ≥ 500` (повтор `refresh` безопасен: в Jira он не пишет). `Runner` берёт источник в текст: лог + «обрыв связи с хабом, повтор k/3…», итог «результат неизвестен (обрыв связи с хабом), повтор безопасен: хаб: …». + Для ошибок Jira тексты прежние. +13. **M-5 #18.** `int(resp.get("status") or 0)` в `FolderTransport._take` заменяется на уже проверенные `status`, `body`. +14. `AGENT_VERSION = 9`: номера 6–8 заняты живой проверкой 14.10.2026 (опубликована v8), `publish` принимает только + версию выше опубликованной. + +### Фейки и тесты + +15. **`fake_jira.search_keys`** учитывает `project = ` (префикс ключа) и `created >= -d` относительно часов + фейка `now` (по умолчанию текущее время); `add()` по умолчанию ставит `created = now − 1 ч`, `POST /issue` — + `created = now`. Тесты: задача старше 7 дней и задача чужого проекта не считаются дублем в `ensure_issue`. +16. **`fake_hub.claim_wait`** (по умолчанию 0): при пустой очереди фейк держит `claim` до `claim_wait` секунд, как + хаб. Тест: остановка агента во время долгого `claim` — агент выходит после ответа, без команды и без записи в журнал. +17. **Новые тесты запускателя:** `on_sigterm` (пересылка дочернему, без нового запуска); `trial` при перезапуске + `run.py` (sha совпадает — пробный период продолжается; не совпадает — `rolled_back`); строки таблицы решения 1; + решение 3 (`ok` в последний момент); решение 4 (`OSError` до и после замены, через подмену `os.replace`/`open`); + решение 6 (код 73 в `trial` — без отката). Агент: решения 8–12; будущее время журнала + остановка в паузе + (M-3 #18: `claimed` есть, `started` нет). + +### Выкатка + +18. Порядок: merge PR jira-hub (спека) и jira-agent → `jira-hub agent publish src/agent.py` (v9) из основного checkout + агента → `agent_status` показывает «обновление: ok v8 → v9» → владелец останавливает `run.py` (launchctl unload), + копирует `run.py` v2 в `~/jira-agent-bundle`, запускает (launchctl load) → `agent_status` показывает «запускатель: v2». + Запускатель v1 с агентом v9 работает (код 73 для него — обычное падение, как раньше с 1); запускатель v2 с агентом + v8 — тоже (v8 выходит с 1). + +## Risks / Trade-offs + +- [Устаревший `trial` → `rolled_back`] ручная подмена `agent.py` блокирует автоустановку той же версии → публикация + с номером выше. +- [`rejected` после `OSError` в подмене] версия не ставится, пока не опубликована следующая → видно в `agent_status` + («v отвергнута: подмена не удалась…»). +- [sha256 на каждом запуске] при повреждённом `update.json` (нет `sha256`) пробного периода нет → строка «иное» таблицы, + агент работает как подтверждённый; это поведение v1 при неизвестном состоянии. +- [Код 73 от осиротевшего агента в `trial`] новая версия не стартует, пока жив чужой процесс; пробный период каждый раз + заново → в логе запускателя видно «журнал занят». + +## Decisions made without discussion + +| Маркер | Дата | Решение | Обоснование | Цена ошибки | +|---|---|---|---|---| +| R-1 (design #6, #8) | 2026-10-14 | Код «журнал занят» — 73 (`EX_CANTCREAT`) | рядом с 75 (`EX_TEMPFAIL`) из sysexits; не пересекается с 0/1/2/3/130 | смена кода — правка двух файлов и спеки | +| R-2 (design #12) | 2026-10-14 | Подлежащее «хаб: » добавляется при обёртке в `refresh`, а не в тексте `FolderTransport` | `check` уже печатает «хаб: НЕДОСТУПЕН — <текст>»; «хаб» в тексте транспорта дал бы «хаб: НЕДОСТУПЕН — хаб не ответил» | нет | +| R-3 (design #10) | 2026-10-14 | Неверный формат `agent_release` — молча игнорировать с логом раз в час, без `rejected` | версия неизвестна — записать `rejected` не для чего; хаб такого не отдаёт | лишний час до заметной строки в логе | +| R-4 (design #3) | 2026-10-14 | `ok` в последний момент — перезапуск без паузы | агента остановил запускатель, а не сбой | нет | +| R-5 (design #15, #16) | 2026-10-14 | Фейки: только `project`, `created` и долгий `claim`; 400 на `key in`, agile без `fields`, `PUT null` уже как в Jira | проверено по коду фейков | нет | diff --git a/openspec/changes/agent-v9-hardening/proposal.md b/openspec/changes/agent-v9-hardening/proposal.md new file mode 100644 index 0000000..05e3a29 --- /dev/null +++ b/openspec/changes/agent-v9-hardening/proposal.md @@ -0,0 +1,58 @@ +## Why + +Самообновление агента работает с 14.10.2026 (v5 → v8 на виртуалке, живая проверка пройдена), но финальное ревью +change'а `agent-self-update` оставило отложенные находки в запускателе `run.py` и `Updater` (aep/jira-agent#22): +`run.py` верит `update.json` на слово, и после падения запускателя посреди подмены или отката может откатить +здоровую версию, оставить новую версию без пробного периода или уйти в цикл «10 минут работы → SIGTERM». +Полный диск роняет агента трейсбеком и запускатель — в цикл падений. Рядом лежат мелкие находки гигиены +(aep/jira-agent#18) и неточность фейков Jira/хаба в тестах агента (aep/jira-agent#4, M-10): ошибку `ensure_sprint` +(`KeyError: 'fields'`) 13.10.2026 нашли только на живой Jira. + +Решения владельца 14.10.2026 (brainstorming): чинить всё одним пакетом — агент v9 (через самообновление) и +запускатель v2 (владелец переставляет вручную); фейки подтянуть к наблюдённому поведению Jira, а не отклонять. + +## What Changes + +- **Запускатель `run.py` v2** (`LAUNCHER_VERSION = 2`): + - сверка при старте: `update.json` сравнивается с sha256 файла `agent.py` и наличием `agent.py.new`; устаревший + `trial` (агент не та версия) → `rolled_back` без отката; `staged` при уже подменённом `agent.py` → `trial`; + `staged` без годного `agent.py.new` → `rejected`; удаление `agent.py.new.tmp`, `agent.py.prev.tmp`, `update.json.tmp`; + - пробный период включается, только если sha256 `agent.py` равен sha256 версии в `update.json`; + - после истечения пробного периода и остановки агента `update.json` перечитывается: `ok` — отката нет; + - код выхода агента 73 («другой процесс держит журнал») — не сбой версии: без отката, перезапуск с паузой; + - `OSError` при подмене — `rejected` «подмена не удалась», работает текущая версия; запускатель не падает. +- **Агент v9** (`AGENT_VERSION = 9`): + - `run` при занятой блокировке журнала выходит с кодом 73 (было 1); + - `OSError` при подготовке версии — временный сбой (повтор через 5 мин), не трейсбек; + - `agent_release` неверного формата (версия не целое, sha256 не 64 hex-символа) игнорируется, в логе не чаще раза в час; + - время журнала в будущем — одна строка лога вместо двух противоречивых; + - ошибки хаба внутри `refresh` помечаются источником «хаб» (лог и `error` отчёта), `FolderTransport` пишет «хаб не + ответил через папку…»; + - тесты: будущее время журнала + остановка в паузе, `on_sigterm` запускателя, `trial` при перезапуске `run.py`; + - фейки: `project =` и `created >= -Nd` в поиске, очистка Epic Link `null`, 400 на `key in (...)` с несуществующим + ключом, долгий `claim` хаба. + +## Capabilities + +### New Capabilities + + +### Modified Capabilities +- `agent`: код выхода при занятом журнале; источник ошибки хаба в `refresh`; лог при времени журнала в будущем; + самообновление — `OSError` и формат `agent_release`; запускатель — сверка состояния с файлом, повторная проверка + `ok`, код 73, `OSError` при подмене, очистка временных файлов. + +## Non-Goals + +- код хаба не меняется: `agent_release.sha256` у хаба всегда строка (`release.current`), формат проверяет агент; +- самообновление `run.py` — по-прежнему вручную; +- повтор подмены в запускателе после `OSError` (у запускателя нет таймера; лечится публикацией v+1, см. design R-2); +- новые состояния `update.json` — используются существующие `staged`, `trial`, `ok`, `rolled_back`, `rejected`. + +## Impact + +- jira-hub: только `openspec/specs/agent/spec.md` (через этот change). Код, схема БД, API, MCP — без изменений. +- jira-agent (aep/jira-agent#23): `src/agent.py` (v9), `src/run.py` (v2), `tests/` (фейки и новые тесты), README, + ARCHITECTURE.md, CLAUDE.md. +- Выкатка, порядок важен: сначала `jira-hub agent publish` v9 (самообновление с v8), затем владелец вручную ставит + `run.py` v2. Запускатель v2 с агентом v8 работает, но код 73 понимает только от v9. diff --git a/openspec/changes/agent-v9-hardening/specs/agent/spec.md b/openspec/changes/agent-v9-hardening/specs/agent/spec.md new file mode 100644 index 0000000..a56dfdd --- /dev/null +++ b/openspec/changes/agent-v9-hardening/specs/agent/spec.md @@ -0,0 +1,234 @@ +## MODIFIED Requirements + +### Requirement: Цикл выполнения +Агент SHALL работать одним процессом: `claim` к хабу (долгий запрос) → при получении команды: досыпать до +`pause_seconds` от последнего вызова Jira → запись «started» в журнал → выполнение по контракту `commands` → запись +«finished» с результатом → отчёт хабу, повторяемый до подтверждения. Одна команда в полёте: новый `claim` агент SHALL +отправлять, только когда у него нет выданной, но не завершённой и не отчитанной команды (на этом хаб строит повторную +выдачу, см. `commands`). Один процесс на журнал: `run` SHALL брать исключительную блокировку файла `<журнал>.lock` и, +если она занята, завершаться с кодом 73 и сообщением «агент уже запущен». При изменении `settings_version` в ответе +`claim` агент SHALL перечитать настройки до выполнения команды. Если команда упала из-за обрыва связи (с Jira или, +в `refresh`, с хабом), лог повтора и итоговая ошибка SHALL называть источник: «обрыв связи с Jira» или «обрыв связи +с хабом». + +#### Scenario: Остановка между командами +- **WHEN** получен Ctrl-C во время выполнения команды +- **THEN** текущая команда доделывается и отчёт отправляется, затем процесс завершается + +#### Scenario: Второй процесс с тем же журналом +- **WHEN** агент работает, и запускается ещё один `agent.py run` с тем же `journal.jsonl` +- **THEN** второй процесс пишет «агент уже запущен…» и завершается с кодом 73, не обращаясь к хабу и Jira + +#### Scenario: Остановка во время долгого claim +- **WHEN** очередь пуста, хаб держит `claim`, агент получил Ctrl-C +- **THEN** агент дожидается ответа `claim`, завершается без команды; журнал не изменён + +### Requirement: Ограничение темпа и 429 +Агент SHALL помнить время последнего вызова Jira (любого) и не начинать команду раньше `pause_seconds` после него. При +старте агент SHALL принять за время последнего вызова Jira самое позднее `at` в журнале; если оно в будущем (часы +сдвинулись назад) — текущий момент (пауза перед первой командой — полная `pause_seconds`) и одна строка лога о времени +в будущем, без строки «последний вызов Jira не позже …»; при пустом журнале паузы перед первой командой нет. Оценка +из журнала служит только паузе: в `claim` агент SHALL передавать `last_jira_call_at` только после настоящего вызова +Jira в текущем процессе, до него — `null`. На HTTP 429 и 503 агент SHALL ждать `Retry-After` (иначе 60 с × номер +попытки) и повторять тот же запрос до 5 раз, считая попытки в `rate_limited`; после 5 неудач — `failed`. Пауза между +записями внутри одной команды — по зазору типа команды. + +#### Scenario: 429 с Retry-After +- **WHEN** Jira ответила 429 с `Retry-After: 90` +- **THEN** агент ждёт 90 с, повторяет тот же запрос; в отчёте `rate_limited = 1` + +#### Scenario: Перезапуск не сбивает темп +- **WHEN** последняя запись журнала `finished` сделана 10 с назад, агент перезапущен и получил команду с `pause_seconds = 30` +- **THEN** агент ждёт около 20 с перед первым вызовом Jira по команде + +#### Scenario: Время журнала в будущем +- **WHEN** последняя запись журнала на 2 часа позже текущего времени, агент перезапущен и получил команду с `pause_seconds = 30` +- **THEN** агент ждёт 30 с перед первым вызовом Jira; в логе одна строка — что время журнала в будущем и пауза полная + +#### Scenario: Остановка в паузе при времени журнала в будущем +- **WHEN** время журнала в будущем, агент получил команду с `pause_seconds = 30` и через 5 с получил Ctrl-C +- **THEN** в журнале `claimed` этой команды без `started`, в Jira нет вызовов; после перезапуска команду подхватывает + восстановление + +#### Scenario: После перезапуска хаб не получает оценку из журнала +- **WHEN** агент перезапущен с непустым журналом и отправляет первый `claim` +- **THEN** `last_jira_call_at` в `claim` — `null`; после первого вызова Jira следующий `claim` несёт его время + +### Requirement: Чтение состояния +`refresh` SHALL выполняться запросом `search` (`jql`, `fields` = поля зеркала включая `issuelinks` и спринт, +`maxResults = 100`, постранично) и отправляться хабу пачками по 100 задач со связями; ключи из `issuelinks`, которых нет +в выборке и в зеркале хаба (хаб сообщает список известных), агент SHALL дочитать одним `search` по `key in (...)` +и отправить с признаком `neighbor`. Ошибка хаба при запросе известных ключей или отправке пачки SHALL попадать в +`error` с префиксом «хаб: »; недоступность хаба и его 5xx повторяются, как обрыв связи, с источником «хаб». + +#### Scenario: Полная синхронизация +- **WHEN** `refresh {jql: "project = AURORA AND labels = AWPCA"}` при 180 задачах +- **THEN** 2 вызова `search` + 1 на соседей; `sync_run.issues_count = 180 + соседи` + +#### Scenario: Хаб не ответил при отправке состояния +- **WHEN** `refresh` через папку, реле не ответило на отправку пачки ни в одной из 4 попыток +- **THEN** отчёт `failed` с ошибкой «результат неизвестен (обрыв связи с хабом), повтор безопасен: хаб: не ответил через + папку …»; в логе повторов — «обрыв связи с хабом» + +### 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`. Публикацию, в которой `version` не целое число или `sha256` не строка из 64 +символов `[0-9a-f]`, агент SHALL игнорировать и писать об этом в лог не чаще раза в час. Версию с теми же `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 символами +вывода. Ошибка файловой системы (`OSError`) на любом шаге записи или запуска SHALL быть временным сбоем. Годную версию +агент 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** запись `agent.py.new.tmp` завершилась `OSError` (нет места) +- **THEN** временного файла и `agent.py.new` нет, `update.json` не изменён, агент работает дальше без трейсбека; + следующая попытка — не раньше чем через 5 минут + +#### Scenario: Неполная публикация +- **WHEN** `claim` вернул `agent_release = {version: 9, sha256: null}` +- **THEN** агент не запрашивает `GET /agent/release`, не пишет `update.json`; в логе «хаб прислал неполную публикацию» + не чаще раза в час + +#### 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` (2) и унаследованными stdout/stderr. Запускатель SHALL брать +неблокирующую блокировку `run.py.lock`; занята — «запускатель уже работает», код 1. SIGINT при живом дочернем процессе +запускатель SHALL только запоминать как остановку человеком (Ctrl-C получает и агент), без дочернего процесса (пауза +перед перезапуском) — завершаться; SIGTERM — пересылать дочернему процессу. После сигнала остановки запускатель MUST NOT +запускать агента снова. Ошибка записи `update.json` (`OSError`) MUST NOT завершать запускатель: он пишет её в лог и +продолжает. + +При старте запускатель SHALL удалять `agent.py.new.tmp`, `agent.py.prev.tmp`, `update.json.tmp`, восстанавливать +`agent.py` из `agent.py.prev`, если первого нет, и сверять `update.json` с файлом `agent.py` (`S` — `sha256` из +`update.json`, `A` — sha256 файла `agent.py`): +- `staged` и `A = S` — подмена уже сделана: `agent.py.new` удалить, записать `trial`; +- `staged`, `A ≠ S`, `agent.py.new` есть и его sha256 `= S` — подмена (ниже); +- `staged`, `A ≠ S`, `agent.py.new` нет или его sha256 `≠ S` — `agent.py.new` удалить, `rejected` «подготовленный файл + потерян»; +- `trial` и `A ≠ S` — `rolled_back` «пробный период прерван: agent.py не v», файлы не трогаются; +- в остальных случаях `agent.py.new` (если есть) удаляется. + +Пробный период SHALL действовать для запуска агента, только если `update.json` = `trial` и sha256 `agent.py` равен +`update.json.sha256`; при несовпадении запускатель SHALL записать `rolled_back`, как при старте, и запустить агента без +пробного периода. + +По коду выхода дочернего процесса: +- `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` «файл изменился после проверки», запуск текущей версии; если перед этим был Ctrl-C — + после подмены запускатель завершается, пробный период начнётся при следующем старте. `OSError` до замены + `agent.py` — удалить `agent.py.new` и `agent.py.prev.tmp`, `rejected` «подмена не удалась: <ошибка>», запуск текущей + версии; `OSError` при записи `trial` после замены — вернуть `agent.py.prev` на место `agent.py` и записать + `rejected` «подмена не удалась: <ошибка>»; +- `73` — журнал занят другим процессом: не сбой версии, отката нет и в пробном периоде; лог «агент уже запущен другим + процессом (журнал занят)», перезапуск с паузой, как после падения; +- иной код при действующем пробном периоде — откат; иначе — перезапуск той же версии с паузой 5 с, удваиваемой до + 300 с при каждом следующем падении и сбрасываемой после 10 минут работы. + +Пока действует пробный период, запускатель SHALL ждать состояния `ok` не дольше 10 минут; по истечении — SIGTERM, +через 60 с SIGKILL; затем запускатель SHALL перечитать `update.json`: `ok` — отката нет, та же версия запускается без +паузы; иначе — откат. Откат: `agent.py.prev` возвращается на место `agent.py`, `update.json` = `rolled_back` с причиной +(«код через с» или «не вышла на связь за 10 мин»), прежняя версия запускается без паузы. Файл `agent.py` MUST +существовать на каждом шаге подмены и отката. + +#### 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, sha256 `agent.py` совпадает, дочерний процесс вышел с кодом 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, перечитывает `update.json` и, если там + не `ok`, делает откат с причиной «не вышла на связь за 10 мин» + +#### Scenario: Подтверждение в последний момент +- **WHEN** пробный период истёк, запускатель послал SIGTERM, а агент до выхода записал `ok` +- **THEN** отката нет; `agent.py` — новая версия, она запускается снова без паузы + +#### Scenario: Новая версия сама вышла с кодом 0 +- **WHEN** `update.json` = `trial` для v7, дочерний процесс вышел с кодом 0, сигналов запускатель не получал +- **THEN** откат на v6 с причиной «код 0 через с», как при падении + +#### Scenario: Падение после подтверждения +- **WHEN** `update.json` = `ok`, агент дважды подряд упал с кодом 1, проработав меньше минуты +- **THEN** запускатель перезапускает ту же версию через 5 с, затем через 10 с; отката нет + +#### Scenario: Падение запускателя между подменой и записью trial +- **WHEN** `update.json` = `staged` для v9 с sha256 `S`, `agent.py` уже v9 (sha256 `S`), `agent.py.new` нет; запускатель + стартует +- **THEN** `update.json` = `trial` для v9, запущен v9 в пробном периоде; после его первого `claim` — `ok` + +#### Scenario: Устаревший пробный период +- **WHEN** `update.json` = `trial` для v9 с sha256 `S`, а `agent.py` — v8 (откат был прерван); запускатель стартует +- **THEN** `update.json` = `rolled_back` «пробный период прерван: agent.py не v9», v8 запущен без пробного периода и + через 10 минут не останавливается; v9 с sha256 `S` агент больше не ставит + +#### Scenario: Журнал занят в пробном периоде +- **WHEN** `update.json` = `trial` для v9, sha256 совпадает, дочерний v9 вышел с кодом 73 (журнал держит осиротевший + агент) +- **THEN** отката нет, `agent.py` — v9, `update.json` = `trial`; лог «журнал занят», перезапуск через 5 с + +#### Scenario: Диск полон при подмене +- **WHEN** дочерний процесс вышел с 75, копирование `agent.py` в `agent.py.prev.tmp` завершилось `OSError` +- **THEN** `agent.py` — прежняя версия, `agent.py.new` и `agent.py.prev.tmp` удалены, `update.json` = `rejected` + «подмена не удалась: …»; запущена прежняя версия; запускатель работает + +#### Scenario: Остановка в терминале +- **WHEN** `python3 run.py` работает в терминале, нажат Ctrl-C +- **THEN** агент доделывает команду и выходит с кодом 0, запускатель завершается с кодом 0; повторный Ctrl-C — агент + выходит с 130, запускатель тоже + +#### Scenario: SIGTERM запускателю +- **WHEN** `run.py` работает под launchd, `launchctl unload` посылает ему SIGTERM +- **THEN** запускатель пересылает SIGTERM агенту, дожидается его выхода и завершается; нового запуска агента нет + +#### Scenario: Второй запускатель +- **WHEN** `run.py` работает, и запускается ещё один `run.py` в том же каталоге +- **THEN** второй пишет «запускатель уже работает» и завершается с кодом 1, агента не запускает From 47941ed0730107b7d8fa390968aece2c89318c71 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 15 Oct 2026 00:48:09 +0400 Subject: [PATCH 2/3] =?UTF-8?q?docs(openspec):=20agent-v9-hardening=20?= =?UTF-8?q?=E2=80=94=20=D1=80=D0=B5=D1=88=D0=B5=D0=BD=D0=B8=D0=B5=2010:=20?= =?UTF-8?q?=D1=80=D0=B0=D0=B7=D0=B4=D0=B5=D0=BB=D1=8C=D0=BD=D1=8B=D0=B5=20?= =?UTF-8?q?=D1=82=D0=B0=D0=B9=D0=BC=D0=B5=D1=80=D1=8B=20=D0=BF=D0=BE=D0=B4?= =?UTF-8?q?=D1=81=D0=BA=D0=B0=D0=B7=D0=BE=D0=BA=20(R-9=20=D0=B0=D0=B3?= =?UTF-8?q?=D0=B5=D0=BD=D1=82=D0=B0,=20#26)?= 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-v9-hardening/design.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openspec/changes/agent-v9-hardening/design.md b/openspec/changes/agent-v9-hardening/design.md index 5eed838..a2b099a 100644 --- a/openspec/changes/agent-v9-hardening/design.md +++ b/openspec/changes/agent-v9-hardening/design.md @@ -92,7 +92,7 @@ с `OSError` — только лог: агент работает дальше. 10. **Формат `agent_release`.** `Updater.check` принимает публикацию, только если `version` — целое (не `bool`) и `sha256` — строка из 64 символов `[0-9a-f]`. Иначе — `False` и лог «хаб прислал неполную публикацию» не чаще раза - в час (тот же таймер, что у подсказки про `run.py`). + в час (свой таймер, отдельный от подсказки про `run.py`, — рулинг R-9 агента: одна подсказка не глушит другую). 11. **Время журнала в будущем (M-2 #18).** `restore_pace` пишет одну строку «журнал: время последней записи в будущем (<время>) — пауза перед первой командой полная» и не пишет вторую. 12. **Источник ошибки в `refresh` (M-6 #18).** Вызовы хаба в `do_refresh` (`known_keys`, `state`) оборачиваются: From 2c8cc61ca43202b0657c30b072a487b80a9edae4 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 15 Oct 2026 00:51:34 +0400 Subject: [PATCH 3/3] =?UTF-8?q?docs(openspec):=20=D0=B0=D1=80=D1=85=D0=B8?= =?UTF-8?q?=D0=B2=20agent-v9-hardening=20(#26)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 +- .../.openspec.yaml | 0 .../2026-10-15-agent-v9-hardening}/design.md | 0 .../proposal.md | 0 .../specs/agent/spec.md | 0 openspec/specs/agent/spec.md | 139 ++++++++++++++---- 6 files changed, 110 insertions(+), 31 deletions(-) rename openspec/changes/{agent-v9-hardening => archive/2026-10-15-agent-v9-hardening}/.openspec.yaml (100%) rename openspec/changes/{agent-v9-hardening => archive/2026-10-15-agent-v9-hardening}/design.md (100%) rename openspec/changes/{agent-v9-hardening => archive/2026-10-15-agent-v9-hardening}/proposal.md (100%) rename openspec/changes/{agent-v9-hardening => archive/2026-10-15-agent-v9-hardening}/specs/agent/spec.md (100%) diff --git a/CLAUDE.md b/CLAUDE.md index d9ac3e5..7e68acc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,7 +1,7 @@ # 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-15-web-app/`). +Хаб моста 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-15-web-app/` и `openspec/changes/archive/2026-10-15-agent-v9-hardening/`). ## Стек и ограничения - Веб-приложение — `web/` (React 19 + TypeScript + Vite, TanStack Query, Vitest, Playwright; Node 24, npm); сборка в `src/jira_hub/web/app/` не в git. diff --git a/openspec/changes/agent-v9-hardening/.openspec.yaml b/openspec/changes/archive/2026-10-15-agent-v9-hardening/.openspec.yaml similarity index 100% rename from openspec/changes/agent-v9-hardening/.openspec.yaml rename to openspec/changes/archive/2026-10-15-agent-v9-hardening/.openspec.yaml diff --git a/openspec/changes/agent-v9-hardening/design.md b/openspec/changes/archive/2026-10-15-agent-v9-hardening/design.md similarity index 100% rename from openspec/changes/agent-v9-hardening/design.md rename to openspec/changes/archive/2026-10-15-agent-v9-hardening/design.md diff --git a/openspec/changes/agent-v9-hardening/proposal.md b/openspec/changes/archive/2026-10-15-agent-v9-hardening/proposal.md similarity index 100% rename from openspec/changes/agent-v9-hardening/proposal.md rename to openspec/changes/archive/2026-10-15-agent-v9-hardening/proposal.md diff --git a/openspec/changes/agent-v9-hardening/specs/agent/spec.md b/openspec/changes/archive/2026-10-15-agent-v9-hardening/specs/agent/spec.md similarity index 100% rename from openspec/changes/agent-v9-hardening/specs/agent/spec.md rename to openspec/changes/archive/2026-10-15-agent-v9-hardening/specs/agent/spec.md diff --git a/openspec/specs/agent/spec.md b/openspec/specs/agent/spec.md index 4d33e84..e2c7a64 100644 --- a/openspec/specs/agent/spec.md +++ b/openspec/specs/agent/spec.md @@ -35,8 +35,10 @@ «finished» с результатом → отчёт хабу, повторяемый до подтверждения. Одна команда в полёте: новый `claim` агент SHALL отправлять, только когда у него нет выданной, но не завершённой и не отчитанной команды (на этом хаб строит повторную выдачу, см. `commands`). Один процесс на журнал: `run` SHALL брать исключительную блокировку файла `<журнал>.lock` и, -если она занята, завершаться с кодом 1 и сообщением «агент уже запущен». При изменении `settings_version` в ответе `claim` агент SHALL перечитать настройки до выполнения -команды. +если она занята, завершаться с кодом 73 и сообщением «агент уже запущен». При изменении `settings_version` в ответе +`claim` агент SHALL перечитать настройки до выполнения команды. Если команда упала из-за обрыва связи (с Jira или, +в `refresh`, с хабом), лог повтора и итоговая ошибка SHALL называть источник: «обрыв связи с Jira» или «обрыв связи +с хабом». #### Scenario: Остановка между командами - **WHEN** получен Ctrl-C во время выполнения команды @@ -44,7 +46,11 @@ #### Scenario: Второй процесс с тем же журналом - **WHEN** агент работает, и запускается ещё один `agent.py run` с тем же `journal.jsonl` -- **THEN** второй процесс пишет «агент уже запущен…» и завершается с кодом 1, не обращаясь к хабу и Jira +- **THEN** второй процесс пишет «агент уже запущен…» и завершается с кодом 73, не обращаясь к хабу и Jira + +#### Scenario: Остановка во время долгого claim +- **WHEN** очередь пуста, хаб держит `claim`, агент получил Ctrl-C +- **THEN** агент дожидается ответа `claim`, завершается без команды; журнал не изменён ### Requirement: Журнал на диске и восстановление Агент SHALL вести `journal.jsonl` (только дозапись) рядом с собой: строки `claimed {command_id, at}` (сразу после @@ -73,11 +79,12 @@ rate_limited, at}`, `acked {command_id, at}`. При старте агент SHA ### Requirement: Ограничение темпа и 429 Агент SHALL помнить время последнего вызова Jira (любого) и не начинать команду раньше `pause_seconds` после него. При старте агент SHALL принять за время последнего вызова Jira самое позднее `at` в журнале; если оно в будущем (часы -сдвинулись назад) — текущий момент (пауза перед первой командой — полная `pause_seconds`); при пустом журнале паузы -перед первой командой нет. Оценка из журнала служит только паузе: в `claim` агент SHALL передавать `last_jira_call_at` -только после настоящего вызова Jira в текущем процессе, до него — `null`. На HTTP 429 и 503 агент SHALL ждать -`Retry-After` (иначе 60 с × номер попытки) и повторять тот же запрос до 5 раз, считая попытки в `rate_limited`; после -5 неудач — `failed`. Пауза между записями внутри одной команды — по зазору типа команды. +сдвинулись назад) — текущий момент (пауза перед первой командой — полная `pause_seconds`) и одна строка лога о времени +в будущем, без строки «последний вызов Jira не позже …»; при пустом журнале паузы перед первой командой нет. Оценка +из журнала служит только паузе: в `claim` агент SHALL передавать `last_jira_call_at` только после настоящего вызова +Jira в текущем процессе, до него — `null`. На HTTP 429 и 503 агент SHALL ждать `Retry-After` (иначе 60 с × номер +попытки) и повторять тот же запрос до 5 раз, считая попытки в `rate_limited`; после 5 неудач — `failed`. Пауза между +записями внутри одной команды — по зазору типа команды. #### Scenario: 429 с Retry-After - **WHEN** Jira ответила 429 с `Retry-After: 90` @@ -89,7 +96,12 @@ rate_limited, at}`, `acked {command_id, at}`. При старте агент SHA #### Scenario: Время журнала в будущем - **WHEN** последняя запись журнала на 2 часа позже текущего времени, агент перезапущен и получил команду с `pause_seconds = 30` -- **THEN** агент ждёт 30 с перед первым вызовом Jira; в логе — что время журнала в будущем +- **THEN** агент ждёт 30 с перед первым вызовом Jira; в логе одна строка — что время журнала в будущем и пауза полная + +#### Scenario: Остановка в паузе при времени журнала в будущем +- **WHEN** время журнала в будущем, агент получил команду с `pause_seconds = 30` и через 5 с получил Ctrl-C +- **THEN** в журнале `claimed` этой команды без `started`, в Jira нет вызовов; после перезапуска команду подхватывает + восстановление #### Scenario: После перезапуска хаб не получает оценку из журнала - **WHEN** агент перезапущен с непустым журналом и отправляет первый `claim` @@ -99,12 +111,18 @@ rate_limited, at}`, `acked {command_id, at}`. При старте агент SHA `refresh` SHALL выполняться запросом `search` (`jql`, `fields` = поля зеркала включая `issuelinks` и спринт, `maxResults = 100`, постранично) и отправляться хабу пачками по 100 задач со связями; ключи из `issuelinks`, которых нет в выборке и в зеркале хаба (хаб сообщает список известных), агент SHALL дочитать одним `search` по `key in (...)` -и отправить с признаком `neighbor`. +и отправить с признаком `neighbor`. Ошибка хаба при запросе известных ключей или отправке пачки SHALL попадать в +`error` с префиксом «хаб: »; недоступность хаба и его 5xx повторяются, как обрыв связи, с источником «хаб». #### Scenario: Полная синхронизация - **WHEN** `refresh {jql: "project = AURORA AND labels = AWPCA"}` при 180 задачах - **THEN** 2 вызова `search` + 1 на соседей; `sync_run.issues_count = 180 + соседи` +#### Scenario: Хаб не ответил при отправке состояния +- **WHEN** `refresh` через папку, реле не ответило на отправку пачки ни в одной из 4 попыток +- **THEN** отчёт `failed` с ошибкой «результат неизвестен (обрыв связи с хабом), повтор безопасен: хаб: не ответил через + папку …»; в логе повторов — «обрыв связи с хабом» + ### Requirement: Доставка и запуск README агента SHALL описывать: первую установку (копирование `agent.py`, `run.py` и `agent.example.json` через общую папку в локальный каталог виртуалки, заполнение `agent.json` с выбором транспорта в `hub_url`, `check`), запуск @@ -150,19 +168,22 @@ README агента SHALL описывать: первую установку ( Агент 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» не чаще раза в час. +`agent_release.version > AGENT_VERSION`. Публикацию, в которой `version` не целое число или `sha256` не строка из 64 +символов `[0-9a-f]`, агент SHALL игнорировать и писать об этом в лог не чаще раза в час. Версию с теми же `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` пишется атомарно -(временный файл и переименование). +вывода. Ошибка файловой системы (`OSError`) на любом шаге записи или запуска SHALL быть временным сбоем. Годную версию +агент 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}`, файл @@ -183,6 +204,16 @@ README агента SHALL описывать: первую установку ( - **WHEN** `selftest` новой версии вернул код 3 - **THEN** `agent.py.new` удалён, `update.json` не изменён, следующая попытка — не раньше чем через 5 минут +#### Scenario: Диск полон при подготовке +- **WHEN** запись `agent.py.new.tmp` завершилась `OSError` (нет места) +- **THEN** временного файла и `agent.py.new` нет, `update.json` не изменён, агент работает дальше без трейсбека; + следующая попытка — не раньше чем через 5 минут + +#### Scenario: Неполная публикация +- **WHEN** `claim` вернул `agent_release = {version: 9, sha256: null}` +- **THEN** агент не запрашивает `GET /agent/release`, не пишет `update.json`; в логе «хаб прислал неполную публикацию» + не чаще раза в час + #### Scenario: Запуск без запускателя - **WHEN** агент v5 запущен `python3 agent.py`, опубликована v6 - **THEN** агент не скачивает файл и не выходит; в логе раз в час «доступна v6; для самообновления запускайте через @@ -195,11 +226,26 @@ README агента SHALL описывать: первую установку ( ### 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 брать +окружения `JIRA_AGENT_LAUNCHER = LAUNCHER_VERSION` (2) и унаследованными stdout/stderr. Запускатель SHALL брать неблокирующую блокировку `run.py.lock`; занята — «запускатель уже работает», код 1. SIGINT при живом дочернем процессе запускатель SHALL только запоминать как остановку человеком (Ctrl-C получает и агент), без дочернего процесса (пауза перед перезапуском) — завершаться; SIGTERM — пересылать дочернему процессу. После сигнала остановки запускатель MUST NOT -запускать агента снова. +запускать агента снова. Ошибка записи `update.json` (`OSError`) MUST NOT завершать запускатель: он пишет её в лог и +продолжает. + +При старте запускатель SHALL удалять `agent.py.new.tmp`, `agent.py.prev.tmp`, `update.json.tmp`, восстанавливать +`agent.py` из `agent.py.prev`, если первого нет, и сверять `update.json` с файлом `agent.py` (`S` — `sha256` из +`update.json`, `A` — sha256 файла `agent.py`): +- `staged` и `A = S` — подмена уже сделана: `agent.py.new` удалить, записать `trial`; +- `staged`, `A ≠ S`, `agent.py.new` есть и его sha256 `= S` — подмена (ниже); +- `staged`, `A ≠ S`, `agent.py.new` нет или его sha256 `≠ S` — `agent.py.new` удалить, `rejected` «подготовленный файл + потерян»; +- `trial` и `A ≠ S` — `rolled_back` «пробный период прерван: agent.py не v», файлы не трогаются; +- в остальных случаях `agent.py.new` (если есть) удаляется. + +Пробный период SHALL действовать для запуска агента, только если `update.json` = `trial` и sha256 `agent.py` равен +`update.json.sha256`; при несовпадении запускатель SHALL записать `rolled_back`, как при старте, и запустить агента без +пробного периода. По коду выхода дочернего процесса: - `0`, `130` или завершение по SIGINT после Ctrl-C (или после SIGTERM запускателю) — запускатель завершается с тем же @@ -207,29 +253,38 @@ README агента SHALL описывать: первую установку ( - `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` «файл изменился после проверки», запуск текущей версии; если перед этим был Ctrl-C — - после подмены запускатель завершается, пробный период начнётся при следующем старте; -- иной код при `update.json` = `trial` — откат; иначе — перезапуск той же версии с паузой 5 с, удваиваемой до 300 с - при каждом следующем падении и сбрасываемой после 10 минут работы. + после подмены запускатель завершается, пробный период начнётся при следующем старте. `OSError` до замены + `agent.py` — удалить `agent.py.new` и `agent.py.prev.tmp`, `rejected` «подмена не удалась: <ошибка>», запуск текущей + версии; `OSError` при записи `trial` после замены — вернуть `agent.py.prev` на место `agent.py` и записать + `rejected` «подмена не удалась: <ошибка>»; +- `73` — журнал занят другим процессом: не сбой версии, отката нет и в пробном периоде; лог «агент уже запущен другим + процессом (журнал занят)», перезапуск с паузой, как после падения; +- иной код при действующем пробном периоде — откат; иначе — перезапуск той же версии с паузой 5 с, удваиваемой до + 300 с при каждом следующем падении и сбрасываемой после 10 минут работы. -Пока `update.json` = `trial`, запускатель SHALL ждать состояния `ok` не дольше 10 минут; по истечении — SIGTERM, через -60 с SIGKILL и откат. Откат: `agent.py.prev` возвращается на место `agent.py`, `update.json` = `rolled_back` с причиной +Пока действует пробный период, запускатель SHALL ждать состояния `ok` не дольше 10 минут; по истечении — SIGTERM, +через 60 с SIGKILL; затем запускатель SHALL перечитать `update.json`: `ok` — отката нет, та же версия запускается без +паузы; иначе — откат. Откат: `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 с +- **WHEN** `update.json` = `trial` для v7, sha256 `agent.py` совпадает, дочерний процесс вышел с кодом 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 мин» +- **THEN** запускатель посылает SIGTERM, через 60 с при необходимости SIGKILL, перечитывает `update.json` и, если там + не `ok`, делает откат с причиной «не вышла на связь за 10 мин» + +#### Scenario: Подтверждение в последний момент +- **WHEN** пробный период истёк, запускатель послал SIGTERM, а агент до выхода записал `ok` +- **THEN** отката нет; `agent.py` — новая версия, она запускается снова без паузы #### Scenario: Новая версия сама вышла с кодом 0 - **WHEN** `update.json` = `trial` для v7, дочерний процесс вышел с кодом 0, сигналов запускатель не получал @@ -239,11 +294,35 @@ README агента SHALL описывать: первую установку ( - **WHEN** `update.json` = `ok`, агент дважды подряд упал с кодом 1, проработав меньше минуты - **THEN** запускатель перезапускает ту же версию через 5 с, затем через 10 с; отката нет +#### Scenario: Падение запускателя между подменой и записью trial +- **WHEN** `update.json` = `staged` для v9 с sha256 `S`, `agent.py` уже v9 (sha256 `S`), `agent.py.new` нет; запускатель + стартует +- **THEN** `update.json` = `trial` для v9, запущен v9 в пробном периоде; после его первого `claim` — `ok` + +#### Scenario: Устаревший пробный период +- **WHEN** `update.json` = `trial` для v9 с sha256 `S`, а `agent.py` — v8 (откат был прерван); запускатель стартует +- **THEN** `update.json` = `rolled_back` «пробный период прерван: agent.py не v9», v8 запущен без пробного периода и + через 10 минут не останавливается; v9 с sha256 `S` агент больше не ставит + +#### Scenario: Журнал занят в пробном периоде +- **WHEN** `update.json` = `trial` для v9, sha256 совпадает, дочерний v9 вышел с кодом 73 (журнал держит осиротевший + агент) +- **THEN** отката нет, `agent.py` — v9, `update.json` = `trial`; лог «журнал занят», перезапуск через 5 с + +#### Scenario: Диск полон при подмене +- **WHEN** дочерний процесс вышел с 75, копирование `agent.py` в `agent.py.prev.tmp` завершилось `OSError` +- **THEN** `agent.py` — прежняя версия, `agent.py.new` и `agent.py.prev.tmp` удалены, `update.json` = `rejected` + «подмена не удалась: …»; запущена прежняя версия; запускатель работает + #### Scenario: Остановка в терминале - **WHEN** `python3 run.py` работает в терминале, нажат Ctrl-C - **THEN** агент доделывает команду и выходит с кодом 0, запускатель завершается с кодом 0; повторный Ctrl-C — агент выходит с 130, запускатель тоже +#### Scenario: SIGTERM запускателю +- **WHEN** `run.py` работает под launchd, `launchctl unload` посылает ему SIGTERM +- **THEN** запускатель пересылает SIGTERM агенту, дожидается его выхода и завершается; нового запуска агента нет + #### Scenario: Второй запускатель - **WHEN** `run.py` работает, и запускается ещё один `run.py` в том же каталоге - **THEN** второй пишет «запускатель уже работает» и завершается с кодом 1, агента не запускает