jira-agent/README.md
Andrey Epifancev 3a5bd9a109 Агент: оборванное тело — обрыв связи, перенаправления только на свой адрес, README v14 (#43)
По финальному ревью: M-1, M-4, M-6.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 14:36:29 +04:00

28 KiB
Raw Blame History

jira-agent

Клиент моста Jira для виртуалки с доступом к Jira банка. Агент опрашивает хаб (jira-hub на Mac mini), выполняет команды «желаемый результат» в Jira по правилу «проверь, потом сделай», ведёт журнал на диске и возвращает хабу состояние задач. Один файл src/agent.py и запускатель src/run.py, только стандартная библиотека, Python 3.9.

Спека: ../jira-hub/openspec/specs/agent/spec.md (вместе с commands/ и hub-api/). Хаб, очередь команд и зеркало задач живут в репозитории jira-hub; агент с ним общается только по HTTP.

Доставка на виртуалку

  1. Скопировать src/agent.py, src/run.py и src/agent.example.json через общую папку в локальный каталог на виртуалке, например ~/jira-agent/. Запускать из этого каталога, не из общей папки.
  2. Переименовать agent.example.json в agent.json и заполнить:
    • hub_url — http://<адрес Mac mini>:8790, или, если сеть до Mac mini закрыта VPN (Континент), folder:/Volumes/My Shared Files/RSHB/jira-bridge — обмен через общую папку UTM (на хабе должна быть задана настройка relay_dir, см. README хаба);
    • hub_token — агентский токен из jira-hub tokens на Mac mini;
    • jira.base_url — адрес Jira банка;
    • jira.auth — {"type": "basic", "user": ..., "password": ...} или {"type": "bearer", "token": ...};
    • verify_ssl, ca_bundle — при корпоративном сертификате (см. ограничение 2 ниже);
    • timeout — таймаут запроса в секундах (по умолчанию 60);
    • poll_seconds — пауза между опросами хаба, когда команд нет (по умолчанию 5);
    • auto_update — самообновление (по умолчанию true; false — агент не обновляется сам).
  3. agent.json содержит учётные данные Jira. Никуда не копировать и никуда не отправлять, кроме этой виртуалки. Хаб получает от агента только результаты команд и состояние задач, без паролей и токенов Jira.

Остальные настройки (id полей, типы задач, карта переходов, типы связей, темп) агент получает от хаба при старте и при каждом изменении settings_version; в agent.json их быть не должно. Доски Jira (с v12) приходят в параметрах команд: ensure_sprint — board_id, refresh — boards; board_id из настроек — запасной для команд старого хаба.

Проверка

python3 agent.py check

В норме печатает две строки с префиксом времени [ЧЧ:ММ:СС], например:

[10:42:17] хаб: доступен, настройки v3, проект AURORA
[10:42:18] Jira: доступен, пользователь ivanov

(хаб: доступен, настройки v<N>, проект <KEY> и Jira: доступен, пользователь <login>). Код выхода 0. Если хаб или Jira недоступны, строка содержит НЕДОСТУПЕН и текст ошибки, код выхода 1. Команда ничего не пишет в Jira.

python3 agent.py selftest — то же, что check, плюс первая строка jira-agent v<N>; код 0 — всё доступно, 3 — что-то недоступно. Её вызывает предыдущая версия агента перед самообновлением.

Связь с хабом через общую папку

Когда VPN «Континент ZTN» включён, его фильтр закрывает локальную сеть: хаб по http:// недоступен. Тогда в agent.json:

"hub_url": "folder:/Volumes/My Shared Files/RSHB/jira-bridge"

Агент пишет каждый запрос к хабу файлом req/<id>.json в этой папке и ждёт ответ resp/<id>.json от реле хаба (круг ≈ 0,8 с). Если ответа нет до срока — то же, что недоступный хаб по сети: агент пишет в лог и повторяет. Пароль Jira в папку не попадает; в файлах запросов есть только токен хаба. check в этом режиме печатает хаб: доступен через папку … или НЕДОСТУПЕН (нет папки; реле не ответило за 10 с).

Запуск

В терминале:

python3 run.py

В фоне с логом:

nohup python3 -u run.py > agent.log 2>&1 &

run.py запускает agent.py дочерним процессом и обновляет его (раздел «Самообновление»). Запуск python3 agent.py без запускателя работает, но тогда агент себя не обновляет и раз в час пишет «доступна v; для самообновления запускайте через run.py».

Остановка: Ctrl-C в терминале или kill <pid run.py> (не pid агента: агент, вышедший без сигнала запускателю, считается упавшим и перезапускается). Первый сигнал — агент доделывает текущую команду, отправляет отчёт и выходит. Повторный сигнал — немедленный выход (код 130). Остановка во время паузы перед командой безопасна: команда уже записана в журнал как claimed, при следующем запуске агент запрашивает её у хаба и выполняет через проверку, выдержав паузу темпа от последней записи журнала. Если журнал занят другим процессом (осталась прежняя копия агента), agent.py run выходит с кодом 73, а run.py повторяет запуск с паузой и не считает это сбоем версии.

Самообновление

С версии 5 агент обновляется сам. После jira-hub agent publish на Mac mini хаб сообщает новую версию в ответе на claim. Агент в паузе между командами (очередь пуста, отчёты подтверждены) скачивает файл по GET /agent/release, сверяет sha256, компилирует, проверяет AGENT_VERSION, кладёт рядом agent.py.new, запускает его selftest и выходит с кодом 75. run.py копирует текущий agent.py в agent.py.prev, ставит новый и запускает его в пробном режиме: версия подтверждена, когда она получила первый ответ хаба (update.json → ok). Если новая версия упала или не вышла на связь за 10 минут — run.py возвращает agent.py.prev и пишет rolled_back; эту версию агент больше не ставит, хаб показывает откат в agent_status. Код в общую папку не попадает: файлы пишутся только в каталог run.py.

Запускатель v2 при старте и перед каждым запуском сверяет update.json с sha256 файла agent.py: если agent.py уже новая версия, а update.json = staged (запускатель упал посреди подмены), пробный период продолжается; если update.json = trial, а agent.py другая версия (прерван откат или файл заменён вручную), пишется rolled_back «пробный период прерван…» и эта версия больше сама не ставится — вернуть её можно публикацией с номером выше. Если подмена не удалась (OSError, например диск полон), текущая версия остаётся, а новая получает rejected «подмена не удалась»: освободить место и опубликовать следующую версию. Временные файлы *.tmp удаляются при старте.

Файлы рядом с agent.py: update.json (состояние: staged, trial, ok, rolled_back, rejected), agent.py.prev (прежняя версия), run.py.lock. Не удалять во время работы.

Если агент упал уже после подтверждения, run.py перезапускает ту же версию с паузой 5 с, удваивая её до 300 с при повторных падениях. Выключить самообновление: "auto_update": false в agent.json. run.py обновляется вручную (меняется редко).

Автозапуск через launchd (опционально)

Пример ~/Library/LaunchAgents/ru.aepif.jira-agent.plist:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key><string>ru.aepif.jira-agent</string>
    <key>ProgramArguments</key>
    <array>
        <string>/usr/bin/python3</string>
        <string>-u</string>
        <string>/Users/USER/jira-agent/run.py</string>
    </array>
    <key>WorkingDirectory</key><string>/Users/USER/jira-agent</string>
    <key>RunAtLoad</key><true/>
    <key>KeepAlive</key><true/>
    <key>StandardOutPath</key><string>/Users/USER/jira-agent/agent.log</string>
    <key>StandardErrorPath</key><string>/Users/USER/jira-agent/agent.log</string>
</dict>
</plist>

Подставить свой путь вместо USER. Запуск и остановка:

launchctl load ~/Library/LaunchAgents/ru.aepif.jira-agent.plist
launchctl unload ~/Library/LaunchAgents/ru.aepif.jira-agent.plist

WorkingDirectory нужен, чтобы agent.json и journal.jsonl лежали рядом с agent.py.

Журнал

journal.jsonl лежит рядом с agent.py. Это только дозапись: на каждую команду пишутся claimed (сразу после получения из хаба, до паузы), started (после паузы, перед обращением к Jira), finished (результат и счётчики вызовов) и acked (хаб подтвердил приём отчёта). По журналу агент после перезапуска досылает неподтверждённые отчёты и находит команды, полученные или начатые, но не завершённые.

В журнале нет параметров команд (текстов задач и комментариев): только id команды, статус, result, error и счётчики вызовов. Но result и error могут содержать ключи задач и фрагменты ответов Jira, поэтому журнал лучше не выносить с виртуалки.

Очистка подтверждённых записей старше 30 дней:

python3 agent.py prune

При работающем агенте prune откажет («агент запущен…», код 1): сначала остановить агента.

Что делает агент с командами

Типы команд описаны в спеке commands/spec.md. Здесь — что агент делает с каждым:

Тип Что делает
ensure_issue ищет задачу своей темы (reporter = currentUser(), за 7 дней), иначе создаёт с типом из issue_types хаба (task, epic, bug; нет настройки типа — failed «нет настройки issue_types.<тип>» без вызова Jira); возвращает key
ensure_fields читает задачу и пишет только отличающиеся поля
ensure_comment комментарий с маркером в начале: обновляет, если текст изменился, иначе создаёт
ensure_status идёт по пути статусов, на каждом шаге выбирает переход; нет перехода — failed со списком доступных
ensure_link создаёт связь; конфликт без replace — failed; с replace сначала удаляет лишнее
ensure_sprint переносит в спринт доски board_id команды (без него — доски из настроек хаба; активные и будущие спринты) или в бэклог; доска без спринтов (канбан) — failed с текстом Jira
ensure_attachment ищет у задачи вложение с тем же именем и размером — есть: ничего не делает; нет: берёт файл у хаба (/agent/files/{sha256}), сверяет sha256 и размер и загружает в Jira (multipart/form-data); файл удалён с хаба — failed «загрузите заново»; повреждённый файл — повтор, как обрыв связи с хабом. Содержимое файла в журнал и лог не пишется
fetch_attachment читает вложение у задачи (fields=attachment), качает содержимое с <base_url>/secure/attachment/<id>/<имя> (не больше 10 МБ; перенаправление на другой адрес — failed, учётные данные туда не уходят) и отдаёт хабу POST /agent/files; в Jira не пишет. Вложение удалено — failed «удалено из … в Jira». Содержимое файла в журнал и лог не пишется
refresh только читает Jira и отправляет состояние задач в хаб (с v13 — и журнал изменений по history, POST /agent/history; с v14 — список вложений задач без содержимого); ничего не пишет; доски с неизвестным типом (boards, type = null) — запрос /rest/agile/1.0/board/{id}, тип в результате boards; спринты — со всех scrum-досок

Правила работы:

  • Проверь, потом сделай. Каждый тип сначала читает текущее состояние и записывает только разницу.
  • Паузы. Хаб выдаёт команду с pause_seconds — агент досыпает до зазора от последнего вызова Jira. Внутри команды с несколькими записями зазор берётся из pacing настроек хаба. После перезапуска время последнего вызова Jira берётся из журнала (самая поздняя запись), поэтому первая команда тоже ждёт паузу.
  • Одна команда в полёте. Агент берёт следующую команду, только когда предыдущая выполнена и отчёт подтверждён. На этом строится довыдача: если ответ на claim потерян, хаб выдаст ту же команду снова при следующем claim (до 3 выдач, затем failed).
  • Отчёт до подтверждения. Отчёт повторяется каждые 10 с, пока хаб не подтвердит. Ответы 404/409/422 хаб выдаёт, когда команда уже отменена или удалена: агент считает её закрытой и не зацикливается.
  • 429 и 503. Агент ждёт Retry-After из ответа Jira и повторяет запрос: до 5 запросов, то есть не более 4 ожиданий; на пятой неудаче — ошибка команды. Если заголовка нет или он в формате HTTP-date (не разбирается), ждёт 60 с, умноженные на номер попытки. Число ответов 429/503 идёт в rate_limited отчёта.
  • Обрыв связи с Jira (R-10). Если Jira оборвала соединение или ответила 5xx, исход записи неизвестен: агент не пишет finished, ждёт не меньше 30 с и повторяет команду через проверку («проверь, потом сделай») до 3 раз. Счётчики суммируются. После исчерпания — failed «результат неизвестен (обрыв связи с Jira), повтор безопасен». При остановке по сигналу команду подхватит восстановление.
  • Версия. Константа AGENT_VERSION в agent.py (сейчас 14) отправляется в каждом claim, в каждом отчёте (agent_version) и в /agent/state. Версия 4 — сравнение текстов без различия \r\n/\n, полная пауза при времени журнала в будущем, claim без оценки из журнала, prune под блокировкой. Версия 5 — самообновление через run.py. Версия 9 — код 73 при занятом журнале, OSError при подготовке — временный сбой, проверка формата публикации, источник ошибки хаба в refresh (номера 6–8 заняты живой проверкой самообновления). Версия 10 — комментарии задач в состоянии refresh. Версия 11 — тип задачи из issue_types с ошибкой конфигурации (тип bug — «Ошибка»), команда ensure_attachment, устойчивое дочитывание комментариев (M-1, M-2 из #30). Агент v10 на ensure_attachment отвечает failed «неизвестный тип команды»; хаб после выкатки ставит min_agent_version = 11. Версия 12 — доски проектов из параметров команд (ensure_sprint.board_id, refresh.boards), тип доски в результате refresh, спринты со всех scrum-досок; без этих параметров — board_id из настроек, как v11. Хаб с проектами выкатывается только после v12 (design хаба projects, решение 14). Версия 13 — журнал изменений Jira в refresh (параметр history: since — изменившиеся задачи, jql — стартовая загрузка), отправка POST /agent/history, итог в result.history (design хаба change-history). Версия 14 — список вложений задач в состоянии refresh и команда fetch_attachment (design хаба attachment-fetch); перенаправления HTTP — только на адрес клиента (Jira или хаб), на другой адрес — отказ. Агент v13 на fetch_attachment не нужен: хаб до v14 скачивание не ставит (409 «нужен v14»). Если хаб назначил min_agent_version выше, он пишет в agent_status.warning: «обновите агента»; команды при этом выдаются по-прежнему.

Тесты

/usr/bin/python3 -m unittest discover -s tests -t .

Тот же Python 3.9.6, что на виртуалке. Внешних сервисов нет: Jira и хаб заменяют фейки (tests/fake_jira.py, tests/fake_hub.py). tests/test_py39.py проверяет, что agent.py разбирается как Python 3.9 и импортирует только стандартную библиотеку.

Обновление агента

С версии 5 — само (раздел «Самообновление»). Вручную обновляются только run.py и переход с версии 4: остановить старый процесс (Ctrl-C или kill <pid>, дождаться «остановлен»), скопировать agent.py и run.py в каталог агента, python3 agent.py check, запустить python3 run.py (или launchctl unload + load). Два процесса с одним журналом работать не могут: хаб довыдаёт команду без отчёта, и второй процесс выполнял бы её одновременно с первым. Начиная с версии 3 второй процесс не стартует («агент уже запущен…», код 73) — блокировка journal.jsonl.lock рядом с журналом. agent.json, journal.jsonl и update.json не трогать: журнал переживает обновление, а неподтверждённые отчёты досылаются при старте.

Переход на run.py v2: сначала агент должен обновиться до v9 (MCP agent_status — «обновление: ok v8 → v9»); затем launchctl unload ~/Library/LaunchAgents/ru.aepif.jira-agent.plist, скопировать run.py в ~/jira-agent-bundle, launchctl load …; agent_status покажет «запускатель: v2».

Известные ограничения / эксплуатация

В порядке важности:

  1. Остановка во время паузы или между claim и выполнением (R-9). Команда остаётся claimed в хабе, но в журнале есть запись claimed: при перезапуске агент запрашивает её у хаба и выполняет с паузой темпа (время последнего вызова Jira берётся из журнала). Отмена с force и повтор не нужны. Если журнал потерян, хаб довыдаст команду при следующем claim — агент выполнит её через проверку.
  2. HTTPS хаба с корпоративным CA не поддержан. HubClient работает без ca_bundle и verify_ssl: hub_url должен быть http://. Для Jira verify_ssl и ca_bundle работают.
  3. Спринт в refresh — только активные и будущие спринты scrum-досок. Поле sprint берётся из Agile API по этим спринтам (рулинг R-6). У задач закрытых спринтов sprint = null. Задачи канбан-досок и досок неизвестного типа — тоже null. Задача в спринтах двух досок получает имя спринта последней по списку boards. Если scrum-доска стала канбаном, refresh пропускает её и сообщает хабу новый тип (R-36); если доска не отдала спринты по другой причине (удалена, нет доступа), refresh завершается failed с номером доски и текстом Jira (R-32).
  4. Особенности refresh. В result.issues попадают только запрошенные задачи, соседи по связям — в result.neighbors. Если jql вернул пустую выдачу, состояние в хаб не отправляется (pages = 0). Отсутствующие ключи при refresh по keys попадают в result.missing (не больше 50). С v10 у каждой задачи состояния есть comments — все комментарии задачи (автор — отображаемое имя, иначе логин; время и текст — как в Jira). Они приходят тем же search; если Jira отдала не все, агент дочитывает их через /issue/{key}/comment. С v11 комментарий без id пропускается (строка в логе с ключом задачи), а если Jira не дала дочитать комментарии задачи (403 или 404), задача уходит без comments — хаб оставляет прежние; refresh продолжается. С v13 после состояния refresh читает журнал изменений (если хаб передал history): по задачам этого прогона с updated не раньше history.since и по стартовой выборке history.jql. Неполный журнал дочитывается GET /issue/{key}?expand=changelog, всё ещё неполный — в логе «получено N из M» и в result.history.incomplete. Ошибка Jira или хаба (не 2xx) на этом шаге не роняет refresh: текст в result.history.error или backfill_error.
  5. Сравнение текстов без различия переводов строк. Описание и тело комментария сравниваются после замены \r\n на \n (Jira хранит правленное в UI с \r\n); отправляется текст команды как есть. Прочие различия (пробелы, завершающий перевод строки) — изменение.
  6. 429/503 без потолка. Агент ждёт Retry-After в секундах сколько бы он ни был (без верхней границы): не более 4 ожиданий, на пятой неудаче — ошибка. Длинный Retry-After держит очередь. Формат HTTP-date в Retry-After не разбирается: тогда ждётся 60 с × номер попытки. Нецелый Retry-After (например, 1.5) тоже не разбирается: те же 60 с × попытка.