jira-agent/README.md
Andrey Epifancev cd2dafe487 feat(agent): prune под блокировкой журнала, версия 4, README (#15)
T2.3 (agent#4).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 12:48:23 +04:00

201 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# jira-agent
Клиент моста Jira для виртуалки с доступом к Jira банка. Агент опрашивает хаб (`jira-hub` на Mac mini),
выполняет команды «желаемый результат» в Jira по правилу «проверь, потом сделай», ведёт журнал на диске
и возвращает хабу состояние задач. Один файл `src/agent.py`, только стандартная библиотека, Python 3.9.
Спека: `../jira-hub/openspec/specs/agent/spec.md` (вместе с `commands/` и `hub-api/`).
Хаб, очередь команд и зеркало задач живут в репозитории `jira-hub`; агент с ним общается только по HTTP.
## Доставка на виртуалку
1. Скопировать `src/agent.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).
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.
## Связь с хабом через общую папку
Когда 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 agent.py
```
В фоне с логом:
```
nohup python3 -u agent.py > agent.log 2>&1 &
```
Остановка: Ctrl-C или `kill <pid>`. Первый сигнал — агент доделывает текущую команду, отправляет отчёт и выходит.
Повторный сигнал — немедленный выход (код 130). Остановка во время паузы перед командой безопасна: команда уже
записана в журнал как `claimed`, при следующем запуске агент запрашивает её у хаба и выполняет через проверку, выдержав паузу темпа от последней записи журнала.
## Автозапуск через 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/agent.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 дней), иначе создаёт; возвращает `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` (сейчас 4) отправляется в каждом `claim`, в каждом отчёте
(`agent_version`) и в `/agent/state`. Версия 4 — сравнение текстов без различия `\r\n`/`\n`, полная пауза при времени
журнала в будущем, `claim` без оценки из журнала, `prune` под блокировкой.
Если хаб назначил `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
и импортирует только стандартную библиотеку.
## Обновление агента
Остановить старый процесс (Ctrl-C или `kill <pid>`, дождаться «остановлен»), скопировать новый `agent.py` поверх
старого и запустить снова (или `launchctl unload` + `load`). Два процесса с одним журналом работать не могут: хаб
довыдаёт команду без отчёта, и второй процесс выполнял бы её одновременно с первым. Начиная с версии 3 второй процесс
не стартует («агент уже запущен…», код 1) — блокировка `journal.jsonl.lock` рядом с журналом.
`agent.json` и `journal.jsonl` не трогать: журнал переживает обновление, а неподтверждённые отчёты досылаются при старте.
## Известные ограничения / эксплуатация
В порядке важности:
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).
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 с × попытка.