250 lines
26 KiB
Markdown
250 lines
26 KiB
Markdown
# agent Specification
|
||
|
||
## Purpose
|
||
Агент моста `jira-agent`: один файл на Python 3.9 (только stdlib) на виртуалке банка. Получает команды хаба (по HTTP или через общую папку), выполняет их в Jira по правилу «проверь, потом сделай» с политикой темпа, ведёт журнал на диске для восстановления после сбоев и возвращает хабу результаты и состояние задач. Учётные данные Jira есть только у агента.
|
||
|
||
## Requirements
|
||
|
||
### Requirement: Состав и настройки агента
|
||
Агент SHALL состоять из файла `agent.py` (Python ≥ 3.9, только стандартная библиотека), запускателя `run.py` (те же
|
||
ограничения) и файла `agent.json`: `hub_url`, `hub_token`, `jira.base_url`, `jira.auth` (`basic` с `user`/`password`
|
||
или `bearer` с `token`), `verify_ssl`, `ca_bundle`, `timeout`, `poll_seconds`, необязательный `auto_update` (по
|
||
умолчанию `true`). `hub_url` вида `http://…` или `https://…` SHALL означать HTTP-транспорт, вида
|
||
`folder:<абсолютный путь>` — транспорт через общую папку. Всё остальное (id полей, типы, карта переходов, типы связей,
|
||
доска, темп) агент SHALL получать от хаба при старте и при изменении `settings_version`. Учётные данные Jira MUST NOT
|
||
отправляться в хаб и MUST NOT попадать в журнал, логи и общую папку. Команды `agent.py`: `run` (по умолчанию), `check`,
|
||
`selftest`, `prune`.
|
||
|
||
#### 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<N>, проект
|
||
<KEY>»; если каталога нет или реле не ответило за 10 с — строка с «НЕДОСТУПЕН» и причиной, код выхода 1
|
||
|
||
#### Scenario: Самопроверка новой версии
|
||
- **WHEN** `python3 agent.py selftest`, хаб и Jira доступны
|
||
- **THEN** печатается «jira-agent v<N>» и строки `check`; код 0; блокировка журнала не берётся, в Jira ничего не пишется;
|
||
если хаб или Jira недоступны — код 3
|
||
|
||
### 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`, `run.py` и `agent.example.json` через общую
|
||
папку в локальный каталог виртуалки, заполнение `agent.json` с выбором транспорта в `hub_url`, `check`), запуск
|
||
`python3 run.py` в терминале и через `nohup` с логом, опциональный launchd-plist с `run.py`, самообновление и
|
||
выключатель `auto_update`, ручное обновление `run.py`, `prune` (только при остановленном агенте, иначе откажет).
|
||
Версия агента SHALL быть в константе `AGENT_VERSION` файла (5 — самообновление) и в каждом `claim` и отчёте; хаб
|
||
предупреждает в `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` («нечитаемый ответ»), процесс не падает; цикл повторяет вызов как при
|
||
недоступном хабе
|
||
|
||
### Requirement: Самообновление агента
|
||
Агент SHALL передавать в каждом `claim` `update_state` (содержимое `update.json` рядом с `agent.py` или `null`) и
|
||
`launcher_version` (из переменной окружения `JIRA_AGENT_LAUNCHER` или `null`). Проверять обновление агент SHALL только
|
||
после ответа `claim` с `command = null`, без сигнала остановки, при `auto_update ≠ false` и
|
||
`agent_release.version > AGENT_VERSION`. Версию с теми же `to_version` и `sha256`, что в `update.json` в состоянии
|
||
`rejected` или `rolled_back`, агент MUST NOT ставить снова. Без `JIRA_AGENT_LAUNCHER` агент MUST NOT выходить для
|
||
обновления: он пишет в лог «доступна v<N>; для самообновления запускайте через run.py» не чаще раза в час.
|
||
|
||
Подготовка версии SHALL идти по шагам: `GET /agent/release`; совпадение `version`/`sha256` ответа с `agent_release`
|
||
(иначе временный сбой); sha256 от `content` в UTF-8 равен `sha256` (иначе `rejected`); `content` компилируется и
|
||
содержит `AGENT_VERSION = <version>` (иначе `rejected`); запись `agent.py.new` атомарно; `python3 agent.py.new selftest`
|
||
с таймаутом 120 с — код 0 годен, код 3 или таймаут — временный сбой, иной код — `rejected` с последними 500 символами
|
||
вывода. Годную версию агент SHALL отметить в `update.json` состоянием `staged` и завершить `run` с кодом 75. При
|
||
`rejected` и временном сбое `agent.py.new` SHALL удаляться, агент продолжает работу; при `rejected` пишется
|
||
`update.json`; повтор после временного сбоя — не раньше чем через 5 минут. Первый успешный `claim` версии, для которой
|
||
`update.json` = `trial` с `to_version = AGENT_VERSION`, SHALL записывать состояние `ok`. `update.json` пишется атомарно
|
||
(временный файл и переименование).
|
||
|
||
#### Scenario: Обновление в паузе
|
||
- **WHEN** агент v5 под запускателем получил `claim` без команды и `agent_release = {version: 6, sha256: S}`, файл
|
||
проходит проверки и `selftest`
|
||
- **THEN** рядом лежит `agent.py.new`, `update.json` = `{state: staged, from_version: 5, to_version: 6, sha256: S}`,
|
||
процесс завершается с кодом 75; журнал не изменился
|
||
|
||
#### Scenario: Команда в очереди
|
||
- **WHEN** опубликована v6, а `claim` агента v5 вернул команду
|
||
- **THEN** агент выполняет команду и отчитывается; к обновлению переходит только после `claim` без команды
|
||
|
||
#### Scenario: Неверный sha256
|
||
- **WHEN** `GET /agent/release` вернул `content`, sha256 которого не равен `sha256`
|
||
- **THEN** `agent.py.new` не остаётся, `update.json` = `rejected` «sha256 не сходится»; следующий `claim` несёт этот
|
||
`update_state`; агент v5 работает дальше и v6 с тем же sha256 больше не скачивает
|
||
|
||
#### Scenario: Хаб недоступен во время selftest
|
||
- **WHEN** `selftest` новой версии вернул код 3
|
||
- **THEN** `agent.py.new` удалён, `update.json` не изменён, следующая попытка — не раньше чем через 5 минут
|
||
|
||
#### Scenario: Запуск без запускателя
|
||
- **WHEN** агент v5 запущен `python3 agent.py`, опубликована v6
|
||
- **THEN** агент не скачивает файл и не выходит; в логе раз в час «доступна v6; для самообновления запускайте через
|
||
run.py»; `claim` несёт `launcher_version = null`
|
||
|
||
#### Scenario: Подтверждение новой версии
|
||
- **WHEN** запускатель запустил v6, `update.json` = `trial` с `to_version = 6`, первый `claim` v6 успешен
|
||
- **THEN** `update.json` = `ok`; лог «обновление до v6 подтверждено»
|
||
|
||
### Requirement: Запускатель run.py
|
||
`python3 run.py [аргументы]` SHALL работать с файлами в каталоге самого `run.py` (`agent.py`, `agent.py.new`,
|
||
`agent.py.prev`, `update.json`) и запускать дочерним процессом `agent.py run` с переданными аргументами, переменной
|
||
окружения `JIRA_AGENT_LAUNCHER = LAUNCHER_VERSION` и унаследованными stdout/stderr. Запускатель SHALL брать
|
||
неблокирующую блокировку `run.py.lock`; занята — «запускатель уже работает», код 1. SIGINT при живом дочернем процессе
|
||
запускатель SHALL только запоминать как остановку человеком (Ctrl-C получает и агент), без дочернего процесса (пауза
|
||
перед перезапуском) — завершаться; SIGTERM — пересылать дочернему процессу. После сигнала остановки запускатель MUST NOT
|
||
запускать агента снова.
|
||
|
||
По коду выхода дочернего процесса:
|
||
- `0`, `130` или завершение по SIGINT после Ctrl-C (или после SIGTERM запускателю) — запускатель завершается с тем же
|
||
кодом; те же коды без сигнала запускателю — падение;
|
||
- `75` — при `update.json` = `staged` и совпадении sha256 `agent.py.new`: копия `agent.py` в `agent.py.prev`, атомарная
|
||
замена `agent.py` на `agent.py.new`, `update.json` = `trial`, запуск новой версии; иначе `agent.py.new` удаляется,
|
||
`update.json` = `rejected` «файл изменился после проверки», запуск текущей версии; если перед этим был Ctrl-C —
|
||
после подмены запускатель завершается, пробный период начнётся при следующем старте;
|
||
- иной код при `update.json` = `trial` — откат; иначе — перезапуск той же версии с паузой 5 с, удваиваемой до 300 с
|
||
при каждом следующем падении и сбрасываемой после 10 минут работы.
|
||
|
||
Пока `update.json` = `trial`, запускатель SHALL ждать состояния `ok` не дольше 10 минут; по истечении — SIGTERM, через
|
||
60 с SIGKILL и откат. Откат: `agent.py.prev` возвращается на место `agent.py`, `update.json` = `rolled_back` с причиной
|
||
(«код <N> через <S> с» или «не вышла на связь за 10 мин»), прежняя версия запускается без паузы. Файл `agent.py` MUST
|
||
существовать на каждом шаге подмены. При старте запускатель SHALL выполнять подмену, если `update.json` = `staged` и `agent.py.new` есть, и удалять
|
||
`agent.py.new` без состояния `staged`.
|
||
|
||
#### Scenario: Подмена версии
|
||
- **WHEN** дочерний v5 вышел с кодом 75, `update.json` = `staged` для v6, sha256 `agent.py.new` совпадает
|
||
- **THEN** `agent.py.prev` — прежний v5, `agent.py` — v6, `update.json` = `trial`, запущен v6
|
||
|
||
#### Scenario: Откат при падении на старте
|
||
- **WHEN** `update.json` = `trial` для v7, дочерний процесс вышел с кодом 1 через 3 с
|
||
- **THEN** `agent.py` — снова v6 (из `agent.py.prev`), `update.json` = `{state: rolled_back, to_version: 7, error: "код 1
|
||
через 3 с"}`, запущен v6; v6 видит `rolled_back` для v7 с тем же sha256 и v7 больше не ставит
|
||
|
||
#### Scenario: Новая версия не выходит на связь
|
||
- **WHEN** `update.json` = `trial`, дочерний процесс работает, но за 10 минут `ok` не записан
|
||
- **THEN** запускатель посылает SIGTERM, через 60 с при необходимости SIGKILL, делает откат с причиной «не вышла на связь
|
||
за 10 мин»
|
||
|
||
#### Scenario: Новая версия сама вышла с кодом 0
|
||
- **WHEN** `update.json` = `trial` для v7, дочерний процесс вышел с кодом 0, сигналов запускатель не получал
|
||
- **THEN** откат на v6 с причиной «код 0 через <S> с», как при падении
|
||
|
||
#### Scenario: Падение после подтверждения
|
||
- **WHEN** `update.json` = `ok`, агент дважды подряд упал с кодом 1, проработав меньше минуты
|
||
- **THEN** запускатель перезапускает ту же версию через 5 с, затем через 10 с; отката нет
|
||
|
||
#### Scenario: Остановка в терминале
|
||
- **WHEN** `python3 run.py` работает в терминале, нажат Ctrl-C
|
||
- **THEN** агент доделывает команду и выходит с кодом 0, запускатель завершается с кодом 0; повторный Ctrl-C — агент
|
||
выходит с 130, запускатель тоже
|
||
|
||
#### Scenario: Второй запускатель
|
||
- **WHEN** `run.py` работает, и запускается ещё один `run.py` в том же каталоге
|
||
- **THEN** второй пишет «запускатель уже работает» и завершается с кодом 1, агента не запускает
|