59 KiB
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/<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; для самообновления
запускайте через 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, первыйclaimv6 успешен - 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и совпадении sha256agent.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, sha256agent.py.newсовпадает - THEN
agent.py.prev— прежний v5,agent.py— v6,update.json=trial, запущен v6
Scenario: Откат при падении на старте
- WHEN
update.json=trialдля v7, sha256agent.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 с sha256S,agent.pyуже v9 (sha256S),agent.py.newнет; запускатель стартует - THEN
update.json=trialдля v9, запущен v9 в пробном периоде; после его первогоclaim—ok
Scenario: Устаревший пробный период
- WHEN
update.json=trialдля v9 с sha256S, аagent.py— v8 (откат был прерван); запускатель стартует - THEN
update.json=rolled_back«пробный период прерван: agent.py не v9», v8 запущен без пробного периода и через 10 минут не останавливается; v9 с sha256Sагент больше не ставит
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.
GET /rest/api/2/issue/{key}?fields=attachment; вложение с тем жеfilenameи тем жеsizeесть —doneс егоattachment_id, файл у хаба не запрашивается.- Файл —
GET /agent/files/{file}; sha256 и длина декодированногоcontentSHALL совпасть сfileиsize, иначе — временный сбой (повтор, как обрыв связи с хабом); 404 —failed«файл удалён с хаба, загрузите заново». 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_VERSIONSHALL быть 11.
Scenario: Ошибка по имени типа
- WHEN
ensure_issue {issue_type: bug, …},issue_types.bug = {name: "Ошибка"} - THEN в
POST /issueissuetype = {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 с текстом «доска : …» (агент 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 отобрать те, у которых Jiraupdatedне раньшеsince(сравнение моментов времени с учётом смещения), и прочитать их журнал запросомsearchjql = 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 и Yupdatedпозжеsince; у X в журнале записи от вчера и от 09:10 UTC (смена статуса и резолюции), у Y — от 09:30 UTC (правка описания) - THEN один
searchkey in (X,Y)сexpand = changelog; хабу отправлены 3 элемента (у X —seq0 и 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у поля с этим именем idcustomfield_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, у Xupdatedпозже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 «у доски оценка — поле , а не 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