Andrey Epifancev ca231ba2ae docs(openspec): архив agent-self-update (#22)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 22:58:46 +04:00

26 KiB
Raw Blame History

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, первый 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 и унаследованными stdout/stderr. Запускатель SHALL брать неблокирующую блокировку run.py.lock; занята — «запускатель уже работает», код 1. SIGINT при живом дочернем процессе запускатель SHALL только запоминать как остановку человеком (Ctrl-C получает и агент), без дочернего процесса (пауза перед перезапуском) — завершаться; SIGTERM — пересылать дочернему процессу. После сигнала остановки запускатель MUST NOT запускать агента снова.

По коду выхода дочернего процесса:

  • 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 — после подмены запускатель завершается, пробный период начнётся при следующем старте;
  • иной код при 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, sha256 agent.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, агента не запускает