# 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, проект »; если каталога нет или реле не ответило за 10 с — строка с «НЕДОСТУПЕН» и причиной, код выхода 1 #### Scenario: Самопроверка новой версии - **WHEN** `python3 agent.py selftest`, хаб и Jira доступны - **THEN** печатается «jira-agent v» и строки `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/.json` = `{id, method, path, body, authorization, deadline}` (`id` — `uuid4().hex`, `authorization` — `Bearer `, `deadline` — момент UTC = сейчас + таймаут вызова; для `claim` таймаут не меньше 45 с) через временный файл с именем на точку и переименование, затем опрашивать `resp/.json` каждые 0,1 с. Полученный ответ агент SHALL удалить; `status` 2xx — вернуть разобранное тело, иной — выбросить `HttpError(status, body)` так же, как HTTP-клиент. Если к `deadline` ответа нет, агент SHALL удалить свой `req/.json` (если он ещё там) и выбросить `ConnectionError`. Нечитаемый ответ — не JSON-объект, `status` не целое число или `body` не строка и не `null` (`null` и отсутствие — пустое тело) — SHALL перечитываться до 2 с, затем `ConnectionError`. Чужие файлы в `resp/` агент MUST NOT трогать. Цикл выполнения, журнал, повторы отчёта и восстановление SHALL работать без изменений. #### Scenario: Ответ хаба через папку - **WHEN** агент вызвал `settings`, реле положило `resp/.json` со `status = 200` - **THEN** агент получает настройки, файл ответа удалён #### Scenario: Реле не отвечает - **WHEN** агент вызвал `claim`, а ответа нет 45 с - **THEN** `req/.json` удалён агентом, выброшен `ConnectionError`; цикл пишет «хаб недоступен» и повторяет `claim` через `poll_seconds`, как при недоступном хабе по сети #### Scenario: Код ошибки хаба через папку - **WHEN** агент отправил `result` по команде, которой в хабе нет, реле положило ответ со `status = 404` - **THEN** агент получает `HttpError(404)` и считает команду закрытой, как по HTTP #### Scenario: Ответ с неверными типами - **WHEN** в `resp/.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; для самообновления запускайте через 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, агента не запускает ### Requirement: Тип задачи и вложения Агент (с v11) SHALL брать тип для `ensure_issue` из `issue_types.`: `{id}`, если `id` задан, иначе `{name}`; нет записи или в ней нет ни `id`, ни `name` — ошибка конфигурации «нет настройки issue_types.», `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: , filename: "ОСВ сч. 60 за 2 кв. 2026.xlsx"}`, у X вложений нет - **THEN** один `GET /issue/X`, один `GET /agent/files/`, один `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: {"": "scrum" | "kanban" | null}` (`null` — доска не найдена, нет доступа или тип не `scrum`/`kanban`; причина — в лог агента; `refresh` продолжается). Имена спринтов задач агент SHALL собирать со всех досок типа `scrum` (известного или только что определённого), доски типа `kanban` и `null` пропускать; задача в спринтах двух досок получает последнее найденное имя (как в v11). Если доска, которую хаб передал как `scrum`, не отдала спринты, агент SHALL перепроверить её тип: стала `kanban` — доска пропускается, в результате `boards: {"": "kanban"}` (хаб записывает тип и при прежнем `scrum`); иначе `refresh` — `failed` с текстом «доска : …» (агент 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` без записи. #### 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»