Andrey Epifancev 30d904e1a8 docs(openspec): архив bridge-hygiene (#13)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:22:53 +04:00

14 KiB
Raw Blame History

agent Specification

Purpose

Агент моста jira-agent: один файл на Python 3.9 (только stdlib) на виртуалке банка. Получает команды хаба (по HTTP или через общую папку), выполняет их в Jira по правилу «проверь, потом сделай» с политикой темпа, ведёт журнал на диске для восстановления после сбоев и возвращает хабу результаты и состояние задач. Учётные данные Jira есть только у агента.

Requirements

Requirement: Состав и настройки агента

Агент SHALL состоять из одного файла agent.py (Python ≥ 3.9, только стандартная библиотека) и файла agent.json: hub_url, hub_token, jira.base_url, jira.auth (basic с user/password или bearer с token), verify_ssl, ca_bundle, timeout, poll_seconds. hub_url вида http://… или https://… SHALL означать HTTP-транспорт, вида folder:<абсолютный путь> — транспорт через общую папку. Всё остальное (id полей, типы, карта переходов, типы связей, доска, темп) агент SHALL получать от хаба при старте и при изменении settings_version. Учётные данные Jira MUST NOT отправляться в хаб и MUST NOT попадать в журнал, логи и общую папку.

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

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 и agent.example.json через общую папку, заполнение agent.json (включая выбор транспорта в hub_url), check, запуск в терминале и через nohup с логом, опциональный launchd-plist, prune (только при остановленном агенте, иначе откажет). Версия агента SHALL быть в константе файла (4 — со сравнением текстов без различия переводов строк и уточнённым восстановлением темпа) и в каждом отчёте; хаб предупреждает в 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 («нечитаемый ответ»), процесс не падает; цикл повторяет вызов как при недоступном хабе