# jira-agent Клиент моста Jira для виртуалки с доступом к Jira банка. Агент опрашивает хаб (`sdlc-hub` на Mac mini), выполняет команды «желаемый результат» в Jira по правилу «проверь, потом сделай», ведёт журнал на диске и возвращает хабу состояние задач. Один файл `src/agent.py` и запускатель `src/run.py`, только стандартная библиотека, Python 3.9. Спека: `../sdlc-hub/openspec/specs/agent/spec.md` (вместе с `commands/` и `hub-api/`). Хаб, очередь команд и зеркало задач живут в репозитории `sdlc-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` — агентский токен из `sdlc-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, проект ` и `Jira: доступен, пользователь `). Код выхода 0. Если хаб или Jira недоступны, строка содержит `НЕДОСТУПЕН` и текст ошибки, код выхода 1. Команда ничего не пишет в Jira. `python3 agent.py selftest` — то же, что `check`, плюс первая строка `jira-agent v`; код 0 — всё доступно, 3 — что-то недоступно. Её вызывает предыдущая версия агента перед самообновлением. ## Связь с хабом через общую папку Когда VPN «Континент ZTN» включён, его фильтр закрывает локальную сеть: хаб по `http://` недоступен. Тогда в `agent.json`: ```json "hub_url": "folder:/Volumes/My Shared Files/RSHB/jira-bridge" ``` Агент пишет каждый запрос к хабу файлом `req/.json` в этой папке и ждёт ответ `resp/.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 агента: агент, вышедший без сигнала запускателю, считается упавшим и перезапускается). Первый сигнал — агент доделывает текущую команду, отправляет отчёт и выходит. Повторный сигнал — немедленный выход (код 130). Остановка во время паузы перед командой безопасна: команда уже записана в журнал как `claimed`, при следующем запуске агент запрашивает её у хаба и выполняет через проверку, выдержав паузу темпа от последней записи журнала. Если журнал занят другим процессом (осталась прежняя копия агента), `agent.py run` выходит с кодом 73, а `run.py` повторяет запуск с паузой и не считает это сбоем версии. ## Самообновление С версии 5 агент обновляется сам. После `sdlc-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 Labelru.aepif.jira-agent ProgramArguments /usr/bin/python3 -u /Users/USER/jira-agent/run.py WorkingDirectory/Users/USER/jira-agent RunAtLoad KeepAlive StandardOutPath/Users/USER/jira-agent/agent.log StandardErrorPath/Users/USER/jira-agent/agent.log ``` Подставить свой путь вместо `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` | читает задачу и пишет только отличающиеся поля; с v15 — `story_points` (оценка; поле ищется по `GET /field`, перекрытие — `field_ids.story_points`; не найдено — `failed`, `null` — очистка); с v16 при `board_id` в команде (хаб ставит доску проекта) оценка читается и пишется через `/rest/agile/1.0/issue/{key}/estimation?boardId=…` — как в бэклоге Scrum, без экрана редактирования; `GET /issue` — только если есть другие поля | | `ensure_comment` | комментарий с маркером в начале: обновляет, если текст изменился, иначе создаёт | | `ensure_status` | идёт по пути статусов, на каждом шаге выбирает переход; нет перехода — `failed` со списком доступных | | `ensure_link` | создаёт связь; конфликт без `replace` — `failed`; с `replace` сначала удаляет лишнее | | `ensure_sprint` | переносит в спринт доски `board_id` команды (без него — доски из настроек хаба; активные и будущие спринты) или в бэклог; доска без спринтов (канбан) — `failed` с текстом Jira | | `ensure_sprint_meta` | (v15) имя, даты и цель спринта доски `board_id` команды; спринта нет — создаёт, есть — правит только отличающиеся поля; `rename_to` переименовывает (повтор после переименования безопасен); даты — `ГГГГ-ММ-ДД`, в Jira — 09:00 и 18:00 по локальному времени агента; `goal: ""` очищает цель, отсутствующий `goal` цель не трогает; закрытый спринт не правится — `failed` | | `ensure_sprint_state` | (v15) запуск (`state: active`) или завершение (`state: closed`) спринта доски `board_id` команды; при завершении незавершённые задачи переносятся в `move_open_to` (`backlog` или имя активного/будущего спринта той же доски), затем спринт закрывается; «выполнено» — по id статусов последней колонки доски (не по именам); все проверки — до первой записи; уже в нужном состоянии — `done` | | `ensure_rank` | (v15) ставит задачу `key` выше (`before`) или ниже (`after`) другой задачи, ровно одно из двух; поле ранга ищется по `GET /field`; задача уже на месте — ничего не пишет; `entries` с неуспешным статусом в ответе Jira — `failed` | | `ensure_attachment` | ищет у задачи вложение с тем же именем и размером — есть: ничего не делает; нет: берёт файл у хаба (`/agent/files/{sha256}`), сверяет sha256 и размер и загружает в Jira (`multipart/form-data`); файл удалён с хаба — `failed` «загрузите заново»; повреждённый файл — повтор, как обрыв связи с хабом. Содержимое файла в журнал и лог не пишется | | `fetch_attachment` | читает вложение у задачи (`fields=attachment`), качает содержимое с `/secure/attachment//<имя>` (не больше 10 МБ; перенаправление на другой адрес — `failed`, учётные данные туда не уходят) и отдаёт хабу `POST /agent/files`; в Jira не пишет. Вложение удалено — `failed` «удалено из … в Jira». Содержимое файла в журнал и лог не пишется | | `refresh` | только читает Jira и отправляет состояние задач в хаб (с v13 — и журнал изменений по `history`, `POST /agent/history`; с v14 — список вложений задач без содержимого; с v15 — ранг (`rank`), оценка (`story_points`), история спринтов задач (`sprint_ids`, из поля спринта `search`), данные досок `boards` в первой пачке, пустая пачка с `boards` при пустой выборке); ничего не пишет; доски с неизвестным типом (`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` (сейчас 15) отправляется в каждом `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»). Версия 15 — спринты, ранг и оценки: команды `ensure_sprint_meta`, `ensure_sprint_state`, `ensure_rank`, `story_points` в `ensure_fields`; в `refresh` — `rank`, `story_points`, `sprint_ids` и данные досок `boards`. Поля ищутся по `GET /field` (ранг и спринт — по схеме `schema.custom`, оценка — по имени «Story Points»), перекрытие — `field_ids.rank`/`sprint`/`story_points`. Версия 16 — оценка из `ensure_fields` с `board_id` через `/rest/agile/1.0/issue/{key}/estimation` доски: в Jira банка поле оценки не на экране редактирования, `PUT /issue` его отклоняет (#53). Перед выкаткой проверить поля на Jira банка: имена и id могут отличаться. Агент v14 на новые типы отвечает «неизвестный тип команды»; `min_agent_version` поднимает владелец после выкатки. Если хаб назначил `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 `, дождаться «остановлен»), скопировать `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 с × попытка.