249 lines
23 KiB
Markdown
249 lines
23 KiB
Markdown
# 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` их быть не должно.
|
||
|
||
## Проверка
|
||
|
||
```
|
||
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`:
|
||
|
||
```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<N>; для
|
||
самообновления запускайте через 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
|
||
<?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` | переносит в спринт доски (активные и будущие) или в бэклог |
|
||
| `refresh` | только читает Jira и отправляет состояние задач в хаб; ничего не пишет |
|
||
|
||
Правила работы:
|
||
- **Проверь, потом сделай.** Каждый тип сначала читает текущее состояние и записывает только разницу.
|
||
- **Паузы.** Хаб выдаёт команду с `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` (сейчас 9) отправляется в каждом `claim`, в каждом отчёте
|
||
(`agent_version`) и в `/agent/state`. Версия 4 — сравнение текстов без различия `\r\n`/`\n`, полная пауза при времени
|
||
журнала в будущем, `claim` без оценки из журнала, `prune` под блокировкой. Версия 5 — самообновление через `run.py`.
|
||
Версия 9 — код 73 при занятом журнале, `OSError` при подготовке — временный сбой, проверка формата публикации, источник ошибки хаба в
|
||
`refresh` (номера 6–8 заняты живой проверкой самообновления).
|
||
Если хаб назначил `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` — только активные и будущие спринты доски.** Поле `sprint` берётся из Agile API по
|
||
этим спринтам (рулинг R-6). У задач закрытых спринтов `sprint = null`.
|
||
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` продолжается.
|
||
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 с × попытка.
|