24 KiB
MODIFIED Requirements
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: Ограничение темпа и 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 и спринт,
maxResults = 100, постранично) и отправляться хабу пачками по 100 задач со связями; ключи из issuelinks, которых нет
в выборке и в зеркале хаба (хаб сообщает список известных), агент SHALL дочитать одним search по key in (...)
и отправить с признаком neighbor. Ошибка хаба при запросе известных ключей или отправке пачки SHALL попадать в
error с префиксом «хаб: »; недоступность хаба и его 5xx повторяются, как обрыв связи, с источником «хаб».
Scenario: Полная синхронизация
- WHEN
refresh {jql: "project = AURORA AND labels = AWPCA"}при 180 задачах - THEN 2 вызова
search+ 1 на соседей;sync_run.issues_count = 180 + соседи
Scenario: Хаб не ответил при отправке состояния
- WHEN
refreshчерез папку, реле не ответило на отправку пачки ни в одной из 4 попыток - THEN отчёт
failedс ошибкой «результат неизвестен (обрыв связи с хабом), повтор безопасен: хаб: не ответил через папку …»; в логе повторов — «обрыв связи с хабом»
Requirement: Самообновление агента
Агент 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, агента не запускает