Andrey Epifancev 8c606ff8a0 Архив OpenSpec: sp-estimation-board (#113)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 22:29:43 +04:00

571 lines
59 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# agent Specification
## Purpose
Агент моста `jira-agent`: один файл на Python 3.9 (только stdlib) на виртуалке банка. Получает команды хаба (по HTTP или через общую папку), выполняет их в Jira по правилу «проверь, потом сделай» с политикой темпа, ведёт журнал на диске для восстановления после сбоев и возвращает хабу результаты и состояние задач. Учётные данные Jira есть только у агента.
## 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 — в параметрах команд
(требование «Доски проектов»). Учётные данные 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<N>, проект
<KEY>»; если каталога нет или реле не ответило за 10 с — строка с «НЕДОСТУПЕН» и причиной, код выхода 1
#### Scenario: Самопроверка новой версии
- **WHEN** `python3 agent.py selftest`, хаб и Jira доступны
- **THEN** печатается «jira-agent v<N>» и строки `check`; код 0; блокировка журнала не берётся, в Jira ничего не пишется;
если хаб или Jira недоступны — код 3
### 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: Журнал на диске и восстановление
Агент SHALL вести `journal.jsonl` (только дозапись) рядом с собой: строки `claimed {command_id, at}` (сразу после
получения команды, до паузы), `started {command_id, at}`, `finished {command_id, status, result, error, jira_calls,
rate_limited, at}`, `acked {command_id, at}`. При старте агент SHALL: отправить хабу все `finished` без `acked`; для
`claimed`/`started` без `finished` — запросить команду у хаба по id и, если она в хабе всё ещё `claimed`, выполнить её
заново по правилу «проверь, потом сделай» с паузой `pause_seconds` из ответа хаба, не записывая `claimed` повторно.
Команда `python3 agent.py prune` SHALL удалять из журнала записи с `acked` старше 30 дней; она SHALL брать ту же
блокировку `<журнал>.lock`, что `run`, и при занятой блокировке завершаться с кодом 1 и сообщением «агент запущен»,
не меняя журнал.
#### Scenario: Падение после ответа Jira
- **WHEN** агент упал между `POST /issue` и записью `finished`, затем перезапущен
- **THEN** агент находит в журнале `started` без `finished`, запрашивает команду у хаба, находит созданную задачу поиском
дубля и отчитывается `done` с её ключом; второй задачи нет
#### Scenario: Восстановление без лишней записи
- **WHEN** в журнале `claimed 40` без `finished`, хаб отдаёт команду 40 в `claimed` с `pause_seconds = 30`
- **THEN** в журнал дописываются `started 40`, `finished 40`, `acked 40`; второй строки `claimed 40` нет; перед выполнением
агент выдерживает 30 с от последнего вызова Jira
#### Scenario: prune при работающем агенте
- **WHEN** `agent.py run` работает, и запущен `agent.py prune` с тем же журналом
- **THEN** `prune` пишет «агент запущен…» и завершается с кодом 1; журнал не изменён
### 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`, `comment`, `attachment` и спринт,
`maxResults = 100`, постранично) и отправляться хабу пачками по 100 задач со связями; ключи из `issuelinks`, которых нет
в выборке и в зеркале хаба (хаб сообщает список известных), агент SHALL дочитать одним `search` по `key in (...)`
и отправить с признаком `neighbor`. Ошибка хаба при запросе известных ключей или отправке пачки SHALL попадать в
`error` с префиксом «хаб: »; недоступность хаба и его 5xx повторяются, как обрыв связи, с источником «хаб».
Каждая задача в состоянии (с v10) SHALL нести `comments` — все комментарии задачи в порядке Jira, каждый
`{id, author, created, updated, body}`: `id` — строка; `author` — отображаемое имя автора, иначе его логин, иначе `null`;
`created`, `updated` — время Jira как есть; `body` — текст как есть (пустой — `""`). Если `search` отдал у задачи меньше
комментариев, чем `comment.total`, агент SHALL дочитать комментарии этой задачи через `GET /issue/{key}/comment`
постранично (`maxResults = 100`) и передать полный список. Комментарий без `id` или с пустым `id` агент SHALL отбросить
с записью в лог (ключ задачи), остальные передать. Если дочитывание комментариев задачи вернуло 403 или 404, агент SHALL
передать задачу без поля `comments` (хаб оставит прежние комментарии) с записью в лог; `refresh` продолжается.
Каждая задача в состоянии (с v14) SHALL нести `attachments` — все вложения задачи из поля `attachment` ответа `search`
в порядке Jira, каждое `{id, filename, size, mime_type, author, created}`: `id` — строка; `filename` — имя как есть;
`size` — целое (байт) или `null`; `mime_type` — `mimeType` Jira или `null`; `author` — как у комментария; `created` —
время Jira как есть. Содержимое вложений при `refresh` MUST NOT скачиваться. Вложение без `id` или без имени агент SHALL отбросить с
записью в лог (ключ задачи). Нет поля `attachment` в ответе (поле скрыто настройками Jira) — `attachments = []`.
#### 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` с ошибкой «результат неизвестен (обрыв связи с хабом), повтор безопасен: хаб: не ответил через
папку …»; в логе повторов — «обрыв связи с хабом»
#### Scenario: Комментарии в состоянии
- **WHEN** у AURORA-1620 в Jira два комментария, автор первого — `{name: "petrov", displayName: "Петров Пётр"}`, у автора
второго нет `displayName`, его `name` — `ivanov`
- **THEN** в состоянии AURORA-1620 `comments` из двух элементов в порядке Jira, `author` — «Петров Пётр» и «ivanov»; число
вызовов `search` не изменилось
#### Scenario: Неполный список комментариев
- **WHEN** `search` вернул у AURORA-1620 20 комментариев при `comment.total = 130`
- **THEN** агент дочитал комментарии двумя вызовами `GET /issue/AURORA-1620/comment`; в состоянии 130 комментариев
#### Scenario: Комментарий без id
- **WHEN** у задачи два комментария, у второго `id = null`
- **THEN** в состоянии один комментарий; в логе строка с ключом задачи; пачка принята хабом
#### Scenario: Нет доступа к комментариям
- **WHEN** `GET /issue/AURORA-1620/comment` при дочитывании вернул 403
- **THEN** AURORA-1620 в состоянии без поля `comments`; остальные задачи пачки с комментариями; `refresh` — `done`
#### Scenario: Задача без комментариев
- **WHEN** у задачи в Jira нет комментариев
- **THEN** в её состоянии `comments = []`
#### Scenario: Вложения в состоянии
- **WHEN** у AURORA-1650 в Jira два вложения: «скрин 1.png» (`mimeType = image/png`, 240000 байт, автор
`{name: "potapova", displayName: "Потапова Анна"}`) и «ОСВ 60.xlsx»
- **THEN** в состоянии AURORA-1650 `attachments` из двух элементов в порядке Jira, у первого `filename = "скрин 1.png"`,
`size = 240000`, `mime_type = "image/png"`, `author = "Потапова Анна"`; число вызовов `search` не изменилось, обращений
к содержимому вложений нет
### 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` = «обновите агента», команды выдаются по-прежнему
### Requirement: Транспорт через общую папку
При `hub_url = "folder:<путь>"` агент SHALL обращаться к хабу теми же запросами, что по HTTP, через файлы в `<путь>`:
на каждый вызов записать `req/<id>.json` = `{id, method, path, body, authorization, deadline}` (`id` — `uuid4().hex`,
`authorization` — `Bearer <hub_token>`, `deadline` — момент UTC = сейчас + таймаут вызова; для `claim` таймаут не меньше
45 с) через временный файл с именем на точку и переименование, затем опрашивать `resp/<id>.json` каждые 0,1 с.
Полученный ответ агент SHALL удалить; `status` 2xx — вернуть разобранное тело, иной — выбросить `HttpError(status, body)`
так же, как HTTP-клиент. Если к `deadline` ответа нет, агент SHALL удалить свой `req/<id>.json` (если он ещё там) и
выбросить `ConnectionError`. Нечитаемый ответ — не JSON-объект, `status` не целое число или `body` не строка и не `null` (`null` и отсутствие — пустое тело) — SHALL
перечитываться до 2 с, затем `ConnectionError`. Чужие файлы в `resp/` агент MUST NOT трогать. Цикл выполнения, журнал,
повторы отчёта и восстановление SHALL работать без изменений.
#### Scenario: Ответ хаба через папку
- **WHEN** агент вызвал `settings`, реле положило `resp/<id>.json` со `status = 200`
- **THEN** агент получает настройки, файл ответа удалён
#### Scenario: Реле не отвечает
- **WHEN** агент вызвал `claim`, а ответа нет 45 с
- **THEN** `req/<id>.json` удалён агентом, выброшен `ConnectionError`; цикл пишет «хаб недоступен» и повторяет `claim`
через `poll_seconds`, как при недоступном хабе по сети
#### Scenario: Код ошибки хаба через папку
- **WHEN** агент отправил `result` по команде, которой в хабе нет, реле положило ответ со `status = 404`
- **THEN** агент получает `HttpError(404)` и считает команду закрытой, как по HTTP
#### Scenario: Ответ с неверными типами
- **WHEN** в `resp/<id>.json` лежит `{"id": …, "status": "ok", "body": 5}`
- **THEN** агент выбрасывает `ConnectionError` («нечитаемый ответ»), процесс не падает; цикл повторяет вызов как при
недоступном хабе
### 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<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 символами
вывода. Ошибка файловой системы (`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<to_version>», файлы не трогаются;
- в остальных случаях `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` с причиной
(«код <N> через <S> с» или «не вышла на связь за 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 через <S> с», как при падении
#### 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, агента не запускает
### Requirement: Тип задачи и вложения
Агент (с v11) SHALL брать тип для `ensure_issue` из `issue_types.<issue_type>`: `{id}`, если `id` задан, иначе `{name}`;
нет записи или в ней нет ни `id`, ни `name` — ошибка конфигурации «нет настройки issue_types.<issue_type>», `failed`
без вызова Jira.
`ensure_attachment {key, file, filename, size}` (`size` дописан хабом при выдаче) агент SHALL выполнять так:
0. `size = null` — `failed` «файл удалён с хаба, загрузите заново» без вызова Jira.
1. `GET /rest/api/2/issue/{key}?fields=attachment`; вложение с тем же `filename` и тем же `size` есть — `done` с его
`attachment_id`, файл у хаба не запрашивается.
2. Файл — `GET /agent/files/{file}`; sha256 и длина декодированного `content` SHALL совпасть с `file` и `size`, иначе —
временный сбой (повтор, как обрыв связи с хабом); 404 — `failed` «файл удалён с хаба, загрузите заново».
3. `POST /rest/api/2/issue/{key}/attachments`, `multipart/form-data` с одной частью `file`, имя файла — `filename` в UTF-8
(`filename="…"` и `filename*=UTF-8''…`), заголовок `X-Atlassian-Token: no-check`; результат `attachment_id` из ответа.
Ответ Jira 4xx (в том числе 413) — `failed` с кодом и текстом Jira.
Содержимое файла MUST NOT попадать в журнал и лог (только `file`, `filename`, размер). `AGENT_VERSION` SHALL быть 11.
#### Scenario: Ошибка по имени типа
- **WHEN** `ensure_issue {issue_type: bug, …}`, `issue_types.bug = {name: "Ошибка"}`
- **THEN** в `POST /issue` `issuetype = {name: "Ошибка"}`
#### Scenario: Нет настройки типа
- **WHEN** `ensure_issue {issue_type: bug, …}`, в `issue_types` нет `bug`
- **THEN** `failed` «нет настройки issue_types.bug», вызовов Jira нет
#### Scenario: Загрузка вложения с кириллическим именем
- **WHEN** `ensure_attachment {key: X, file: <sha>, filename: "ОСВ сч. 60 за 2 кв. 2026.xlsx"}`, у X вложений нет
- **THEN** один `GET /issue/X`, один `GET /agent/files/<sha>`, один `POST /issue/X/attachments`; в Jira вложение с этим
именем и размером файла; отчёт `done` с `attachment_id`
#### Scenario: Файл повреждён в пути
- **WHEN** sha256 полученного `content` не равен `file`
- **THEN** вложение в Jira не загружается; команда повторяется как при обрыве связи с хабом
#### Scenario: Старый агент
- **WHEN** агент v10 получил `ensure_attachment`
- **THEN** `failed` «неизвестный тип команды», агент работает дальше (поведение v10, без изменений)
### Requirement: Доски проектов
Агент (с v12) SHALL брать доски Jira из параметров команд, а не из настроек: `ensure_sprint` — из `board_id` команды,
`refresh` — из `boards` (`[{id, type}]`). В `refresh` для каждой доски с `type = null` агент SHALL запросить
`GET /rest/agile/1.0/board/{id}` и вернуть в результате команды `boards: {"<id>": "scrum" | "kanban" | null}` (`null` —
доска не найдена, нет доступа или тип не `scrum`/`kanban`; причина — в лог агента; `refresh` продолжается). Имена
спринтов задач агент SHALL собирать со всех досок типа `scrum` (известного или только что определённого), доски типа
`kanban` и `null` пропускать; задача в спринтах двух досок получает последнее найденное имя (как в v11). Если доска, которую хаб передал как `scrum`, не отдала спринты, агент SHALL перепроверить её тип: стала `kanban` — доска пропускается, в результате `boards: {"<id>": "kanban"}` (хаб записывает тип и при прежнем `scrum`); иначе `refresh` — `failed` с текстом «доска <id>: …» (агент v12, R-36). Команда без `board_id`/`boards`
(хаб до проектов) SHALL выполняться по `settings.board_id`, как в v11. `ensure_sprint` с `board_id` канбан-доски
агент не получает (хаб отказывает при постановке); если Jira всё же ответила, что у доски нет спринтов, — `failed` с
текстом Jira.
#### Scenario: Две доски, одна новая
- **WHEN** `refresh {jql: …, boards: [{id: 4216, type: "scrum"}, {id: 5120, type: null}]}`, доска 5120 в Jira — kanban
- **THEN** агент запрашивает `GET /board/5120` один раз, спринты читает только с доски 4216; результат содержит
`boards: {"5120": "kanban"}`
#### Scenario: Доска не найдена
- **WHEN** `refresh` с `boards: [{id: 9999, type: null}]`, Jira на `GET /board/9999` ответила 404
- **THEN** результат `refresh` — `done` с `boards: {"9999": null}`; в логе агента — доска 9999 и код ответа
#### Scenario: Старый хаб
- **WHEN** агент v12, хаб без проектов, `refresh {jql: …}` без `boards`, `settings.board_id = 4216`
- **THEN** спринты читаются с доски 4216, результат без `boards`
#### Scenario: Спринт по доске из команды
- **WHEN** `ensure_sprint {key: AURORA-1538, sprint: "Спринт 2", board_id: 4216}`
- **THEN** агент ищет «Спринт 2» среди активных и будущих спринтов доски 4216 и ставит задачу в него
### Requirement: Журнал изменений
Если в параметрах `refresh` есть `history`, агент (с v13) SHALL после отправки всех пачек состояния прочитать журнал
изменений (changelog) Jira и отправить его хабу. `history` может содержать `since`, `jql` или оба:
- `since` (регулярная часть) — из задач, выгруженных этим `refresh` (база и соседи), агент SHALL отобрать те, у которых
Jira `updated` не раньше `since` (сравнение моментов времени с учётом смещения), и прочитать их журнал запросом
`search` `jql = key in (...)`, `fields = key`, `expand = changelog`, по 100 ключей; из журнала каждой задачи взять
записи с `created` не раньше `since`. Таких задач нет — вызовов Jira на этом шаге нет.
- `jql` (стартовая загрузка) — агент SHALL выполнить `search` по `history.jql` с `fields = key`, `expand = changelog`
(постранично, `maxResults = 100`) и взять журнал найденных задач целиком. Стартовая часть выполняется первой;
регулярная часть не запрашивает журнал задач, уже прочитанных стартовой (их журнал уже взят целиком).
Если у задачи `changelog.total` больше числа записей в ответе, агент SHALL дочитать журнал
`GET /rest/api/2/issue/{key}?fields=key&expand=changelog`; если и он неполный — передать что есть, записать в лог ключ
и «получено N из M» и добавить ключ в `history.incomplete` результата.
Элемент для хаба — одно поле записи журнала: `{history_id, seq, key, created, author, field_id, field_name, from,
from_string, to, to_string}`: `history_id` — id записи строкой; `seq` — номер элемента в `items` записи, с 0; `created` —
время записи как есть; `author` — отображаемое имя автора, иначе логин, иначе `null`; `field_name` — `field` элемента;
`field_id` — `fieldId` элемента, а если его нет: для `fieldtype = custom` — id поля, найденный по имени в
`GET /rest/api/2/field` (не больше одного такого вызова за `refresh`), для прочих — `field`; не найден — `null`; `from`,
`fromString`, `to`, `toString` — как есть, нестроковое значение — строкой. Запись без `id` или с пустым `created` и
элемент с пустым `field` агент SHALL отбросить с записью в лог (ключ задачи). Элементы SHALL отправляться
`POST /agent/history` пачками до 500; повторы при отправке — как у пачек состояния.
Результат `refresh` SHALL содержать `history = {issues: <задач с хотя бы одним элементом>, changes: <элементов
отправлено>, incomplete: [<ключи>]}`. Ответ не 2xx (`HttpError`) от Jira или хаба MUST NOT переводить `refresh` в
`failed` и MUST NOT вызывать повтор команды: агент SHALL записать ошибку в лог и вернуть `done`, добавив в `history`
текст ошибки (у ошибки хаба — префикс «хаб: ») — `error` при ошибке регулярной части или отправки элементов,
`backfill_error` при ошибке `search` стартовой загрузки (регулярная часть при этом выполняется). Обрыв связи с Jira или
хабом (`ConnectionError`) на шаге журнала обрабатывается, как в остальном `refresh`. Без `history` в параметрах агент
журнал не читает, ключа `history` в результате нет.
#### Scenario: Ничего не менялось
- **WHEN** `refresh {jql: base, history: {since: "2026-10-22T08:45:00Z"}}`, у всех 180 задач `updated` раньше `since`
- **THEN** вызовов `search` с `expand = changelog` нет; результат `history = {issues: 0, changes: 0, incomplete: []}`
#### Scenario: Две задачи изменились
- **WHEN** тот же `refresh`, у X и Y `updated` позже `since`; у X в журнале записи от вчера и от 09:10 UTC (смена статуса и
резолюции), у Y — от 09:30 UTC (правка описания)
- **THEN** один `search` `key in (X,Y)` с `expand = changelog`; хабу отправлены 3 элемента (у X — `seq` 0 и 1 одной записи,
вчерашняя запись не отправлена); результат `history = {issues: 2, changes: 3, incomplete: []}`
#### Scenario: Стартовая загрузка
- **WHEN** `refresh {jql: base, history: {jql: "(base) AND sprint in openSprints() AND statusCategory != Done"}}`, по ней
12 задач
- **THEN** `search` по `history.jql` с `expand = changelog`; журнал 12 задач отправлен целиком
#### Scenario: Задача в обеих выборках
- **WHEN** `history = {since, jql}`, у X записи журнала от 2020 года и от сегодня, X изменилась после `since` и входит в
выборку `history.jql`
- **THEN** хабу отправлены обе записи X; журнал X запрошен одним `search` (стартовым)
#### Scenario: Плановая дата без fieldId
- **WHEN** элемент журнала `{field: "Плановое окончание", fieldtype: "custom"}` без `fieldId`; в `GET /field` у поля с этим
именем id `customfield_10201`
- **THEN** у элемента `field_id = "customfield_10201"`; `GET /field` вызван один раз за `refresh`
#### Scenario: Ошибка Jira при чтении журнала
- **WHEN** `search` с `expand = changelog` для регулярной части ответил 500
- **THEN** состояние в хабе обновлено; `refresh` — `done` с `history.error`, повтора команды нет; в логе — ошибка
#### Scenario: Ошибка стартовой загрузки
- **WHEN** `history = {since: …, jql: …}`, `search` по `history.jql` ответил 400, у X `updated` позже `since`
- **THEN** журнал X отправлен; `refresh` — `done`, в `history` — `backfill_error` с текстом Jira, `error` нет
#### Scenario: Неполный журнал
- **WHEN** у X `changelog.total = 250`, и `search`, и `GET /issue/X?expand=changelog` вернули 100 записей
- **THEN** отправлены 100 записей X; в логе «X: получено 100 из 250»; `history.incomplete = ["X"]`
#### Scenario: Хаб без журнала
- **WHEN** `refresh {jql: base}` без `history`
- **THEN** журнал не читается; в результате нет ключа `history`
### Requirement: Скачивание вложения
Агент v14 SHALL выполнять `fetch_attachment` по контракту `commands` («Скачивание вложения по запросу»). Метод `JiraClient`
для содержимого SHALL отдавать байты ответа без разбора JSON, с тем же счётом вызовов, повтором 429/503 и учётом времени
последнего вызова, что и прочие вызовы Jira. Отправка хабу — `HubClient` (`POST /agent/files`), работает и через общую
папку. В журнал агента (`journal.jsonl`) и в лог содержимое файла MUST NOT попадать — только id, имя, размер и sha256.
#### Scenario: Скачивание через общую папку
- **WHEN** агент с `hub_url = folder:…` выполняет `fetch_attachment` вложения 3 МБ
- **THEN** содержимое уходит хабу одним запросом `POST /agent/files` через папку (тело ≈ 4 МБ base64); отчёт `done` с
`file` и `size`; в `journal.jsonl` нет содержимого файла
### Requirement: Спринты, ранг и оценки
Агент v15 SHALL в каждом `refresh` (по JQL и по ключам) для каждой доски из `boards` команды с типом `scrum` или
`kanban` (известным или только что определённым) прочитать настройки доски (`GET /rest/agile/1.0/board/{id}/configuration`:
колонки и их статусы — по именам статусов; признак kanban-бэклога, если Jira его отдаёт, иначе `null`) и, у `scrum`,
все спринты доски любого состояния (`GET /rest/agile/1.0/board/{id}/sprint` постранично) и приложить к первой пачке
состояния `boards` (см. `hub-api`, «Приём досок и спринтов»). Ошибка чтения настроек доски — доска с `columns: null`,
`kanban_backlog: null`; ошибка чтения списка спринтов scrum-доски — доска в `boards` не входит (хаб сохраняет прежние
данные), имена спринтов задач — по правилам v12; причина в лог; `refresh` продолжается.
Каждая задача в состоянии (с v15) SHALL нести `rank` — значение поля ранга Jira (поле типа LexoRank), `story_points` —
значение поля оценки (`field_ids.story_points`, если задано, иначе поле Jira с именем «Story Points»), и `sprint_ids` —
id всех спринтов задачи из поля спринта Jira (любого состояния). Поле не найдено — ключ у задач не передаётся (хаб
сохраняет прежнее значение) и запись в лог один раз за `refresh`.
Агент v15 SHALL выполнять `ensure_sprint_meta`, `ensure_sprint_state`, `ensure_rank` и `story_points` в
`ensure_fields` по контракту `commands`; сравнение — до записи, повтор без изменений — `done` без записи.
Агент v16 SHALL ставить `story_points` из `ensure_fields` с `board_id` через оценку доски, а не через `PUT /issue`: поле
оценки может не быть на экране редактирования, и Jira отвечает «Field cannot be set. It is not on the appropriate screen,
or unknown» (Jira банка, 25.10.2026), а бэклог Scrum ставит оценку и без экрана. Текущее значение —
`GET /rest/agile/1.0/issue/{key}/estimation?boardId=<board_id>` (`{fieldId, value}`); отличается — `PUT` туда же с
`{"value": …}` (`null` — очистить); сравнение — как у v15 (`3` равно `3.0`, `null` — только `null`). `field_ids.story_points`
для записи с доской не нужен. `GET /issue` и `PUT /issue` агент делает только для остальных полей команды; в `changed` —
`story_points`, если оценка записана. Если в той же команде записан и `PUT /issue`, перед записью оценки — зазор типа
`ensure_fields` (политика темпа). Если `field_ids.story_points` задан и `fieldId` оценки доски с ним не совпадает —
`failed` «у доски <id> оценка — поле <fieldId>, а не story points (<field_ids.story_points>)», без записи. Команда без
`board_id` — оценка через `PUT /issue`, как v15. Ошибка Jira на
`estimation` — `failed` с текстом Jira; остальные поля к этому моменту уже записаны (повтор их не меняет).
#### Scenario: Доска с закрытыми спринтами
- **WHEN** `refresh {jql: …, boards: [{id: 4216, type: "scrum"}]}`, у доски закрытые спринты 1–2, активный 3, будущий 4
- **THEN** первая пачка состояния несёт `boards` с доской 4216, четырьмя спринтами и колонками; у задачи спринта 2,
перенесённой в 3, — `sprint_ids: [2, 3]`
#### Scenario: Нет поля оценки
- **WHEN** в Jira нет поля «Story Points», `field_ids.story_points` не задан
- **THEN** у задач нет ключа `story_points`; `ensure_fields {key: X, story_points: 3}` — `failed` «поле оценки не найдено
в Jira: задайте field_ids.story_points»
#### Scenario: Оценка не на экране редактирования
- **WHEN** агент v16, `ensure_fields {key: AURORA-1704, story_points: 2, board_id: 4216}`, оценка в Jira — `1.0`
- **THEN** `GET /rest/agile/1.0/issue/AURORA-1704/estimation?boardId=4216`, затем `PUT` туда же `{"value": 2}`; `PUT /issue`
нет; отчёт `done` с `changed: ["story_points"]`, `jira_calls = 2`
#### Scenario: Доска оценивает другим полем
- **WHEN** `field_ids.story_points = customfield_15852`, `GET …/estimation?boardId=5120` отдаёт `fieldId: timeoriginalestimate`
- **THEN** `failed` «у доски 5120 оценка — поле timeoriginalestimate, а не story points (customfield_15852)», `PUT` нет
#### Scenario: Оценка уже стоит
- **WHEN** та же команда, оценка в Jira — `2.0`
- **THEN** только `GET …/estimation`; отчёт `done` с `changed: []`, `jira_calls = 1`