Andrey Epifancev 2c8cc61ca4 docs(openspec): архив agent-v9-hardening (#26)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 00:51:34 +04:00

34 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 и, если она занята, завершаться с кодом 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 и спринт, 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: Доставка и запуск

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, первый 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<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 и совпадении 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, агента не запускает