docs(openspec): архив agent-v9-hardening (#26)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-15 00:51:34 +04:00
parent 594c5f8aca
commit 2c8cc61ca4
6 changed files with 110 additions and 31 deletions

View File

@ -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.

View File

@ -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<N>; для самообновления запускайте через 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<N>; для самообновления
запускайте через run.py» не чаще раза в час.
Подготовка версии SHALL идти по шагам: `GET /agent/release`; совпадение `version`/`sha256` ответа с `agent_release`
(иначе временный сбой); sha256 от `content` в UTF-8 равен `sha256` (иначе `rejected`); `content` компилируется и
содержит `AGENT_VERSION = <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<to_version>», файлы не трогаются;
- в остальных случаях `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` с причиной
(«код <N> через <S> с» или «не вышла на связь за 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, агента не запускает