26 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 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 и,
если она занята, завершаться с кодом 1 и сообщением «агент уже запущен». При изменении settings_version в ответе claim агент SHALL перечитать настройки до выполнения
команды.
Scenario: Остановка между командами
- WHEN получен Ctrl-C во время выполнения команды
- THEN текущая команда доделывается и отчёт отправляется, затем процесс завершается
Scenario: Второй процесс с тем же журналом
- WHEN агент работает, и запускается ещё один
agent.py runс тем жеjournal.jsonl - THEN второй процесс пишет «агент уже запущен…» и завершается с кодом 1, не обращаясь к хабу и Jira
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); при пустом журнале паузы
перед первой командой нет. Оценка из журнала служит только паузе: в 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 агент перезапущен с непустым журналом и отправляет первый
claim - THEN
last_jira_call_atвclaim—null; после первого вызова Jira следующийclaimнесёт его время
Requirement: Чтение состояния
refresh SHALL выполняться запросом search (jql, fields = поля зеркала включая issuelinks и спринт,
maxResults = 100, постранично) и отправляться хабу пачками по 100 задач со связями; ключи из issuelinks, которых нет
в выборке и в зеркале хаба (хаб сообщает список известных), агент SHALL дочитать одним search по key in (...)
и отправить с признаком neighbor.
Scenario: Полная синхронизация
- WHEN
refresh {jql: "project = AURORA AND labels = AWPCA"}при 180 задачах - THEN 2 вызова
search+ 1 на соседей;sync_run.issues_count = 180 + соседи
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. Версию с теми же 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 символами
вывода. Годную версию агент 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 агент 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 и унаследованными stdout/stderr. Запускатель SHALL брать
неблокирующую блокировку run.py.lock; занята — «запускатель уже работает», код 1. SIGINT при живом дочернем процессе
запускатель SHALL только запоминать как остановку человеком (Ctrl-C получает и агент), без дочернего процесса (пауза
перед перезапуском) — завершаться; SIGTERM — пересылать дочернему процессу. После сигнала остановки запускатель MUST NOT
запускать агента снова.
По коду выхода дочернего процесса:
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 — после подмены запускатель завершается, пробный период начнётся при следующем старте;- иной код при
update.json=trial— откат; иначе — перезапуск той же версии с паузой 5 с, удваиваемой до 300 с при каждом следующем падении и сбрасываемой после 10 минут работы.
Пока update.json = trial, запускатель SHALL ждать состояния ok не дольше 10 минут; по истечении — SIGTERM, через
60 с SIGKILL и откат. Откат: agent.py.prev возвращается на место agent.py, update.json = rolled_back с причиной
(«код через с» или «не вышла на связь за 10 мин»), прежняя версия запускается без паузы. Файл agent.py MUST
существовать на каждом шаге подмены. При старте запускатель SHALL выполнять подмену, если update.json = staged и agent.py.new есть, и удалять
agent.py.new без состояния staged.
Scenario: Подмена версии
- WHEN дочерний v5 вышел с кодом 75,
update.json=stagedдля v6, sha256agent.py.newсовпадает - THEN
agent.py.prev— прежний v5,agent.py— v6,update.json=trial, запущен v6
Scenario: Откат при падении на старте
- WHEN
update.json=trialдля v7, дочерний процесс вышел с кодом 1 через 3 с - THEN
agent.py— снова v6 (изagent.py.prev),update.json={state: rolled_back, to_version: 7, error: "код 1 через 3 с"}, запущен v6; v6 видитrolled_backдля v7 с тем же sha256 и v7 больше не ставит
Scenario: Новая версия не выходит на связь
- WHEN
update.json=trial, дочерний процесс работает, но за 10 минутokне записан - THEN запускатель посылает SIGTERM, через 60 с при необходимости SIGKILL, делает откат с причиной «не вышла на связь за 10 мин»
Scenario: Новая версия сама вышла с кодом 0
- WHEN
update.json=trialдля v7, дочерний процесс вышел с кодом 0, сигналов запускатель не получал - THEN откат на v6 с причиной «код 0 через
с», как при падении
Scenario: Падение после подтверждения
- WHEN
update.json=ok, агент дважды подряд упал с кодом 1, проработав меньше минуты - THEN запускатель перезапускает ту же версию через 5 с, затем через 10 с; отката нет
Scenario: Остановка в терминале
- WHEN
python3 run.pyработает в терминале, нажат Ctrl-C - THEN агент доделывает команду и выходит с кодом 0, запускатель завершается с кодом 0; повторный Ctrl-C — агент выходит с 130, запускатель тоже
Scenario: Второй запускатель
- WHEN
run.pyработает, и запускается ещё одинrun.pyв том же каталоге - THEN второй пишет «запускатель уже работает» и завершается с кодом 1, агента не запускает