# agent Specification ## Purpose Агент моста `jira-agent`: один файл на Python 3.9 (только stdlib) на виртуалке банка. Получает команды хаба (по HTTP или через общую папку), выполняет их в Jira по правилу «проверь, потом сделай» с политикой темпа, ведёт журнал на диске для восстановления после сбоев и возвращает хабу результаты и состояние задач. Учётные данные Jira есть только у агента. ## Requirements ### Requirement: Состав и настройки агента Агент SHALL состоять из одного файла `agent.py` (Python ≥ 3.9, только стандартная библиотека) и файла `agent.json`: `hub_url`, `hub_token`, `jira.base_url`, `jira.auth` (`basic` с `user`/`password` или `bearer` с `token`), `verify_ssl`, `ca_bundle`, `timeout`, `poll_seconds`. `hub_url` вида `http://…` или `https://…` SHALL означать HTTP-транспорт, вида `folder:<абсолютный путь>` — транспорт через общую папку. Всё остальное (id полей, типы, карта переходов, типы связей, доска, темп) агент SHALL получать от хаба при старте и при изменении `settings_version`. Учётные данные Jira MUST NOT отправляться в хаб и MUST NOT попадать в журнал, логи и общую папку. #### 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, проект »; если каталога нет или реле не ответило за 10 с — строка с «НЕДОСТУПЕН» и причиной, код выхода 1 ### 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 дней. #### 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 ### Requirement: Ограничение темпа и 429 Агент SHALL помнить время последнего вызова Jira (любого) и не начинать команду раньше `pause_seconds` после него. При старте агент SHALL принять за время последнего вызова Jira самое позднее `at` в журнале (если оно не в будущем); при пустом журнале паузы перед первой командой нет. На 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 по команде ### 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` и `agent.example.json` через общую папку, заполнение `agent.json` (включая выбор транспорта в `hub_url`), `check`, запуск в терминале и через `nohup` с логом, опциональный launchd-plist, `prune`. Версия агента SHALL быть в константе файла (3 — с восстановлением темпа после перезапуска и блокировкой одного процесса) и в каждом отчёте; хаб предупреждает в `agent_status`, если версия ниже минимальной из настроек. #### Scenario: Устаревший агент - **WHEN** агент версии 1 выходит на связь, а в настройках `min_agent_version = 2` - **THEN** `agent_status.warning` = «обновите агента», команды выдаются по-прежнему ### Requirement: Транспорт через общую папку При `hub_url = "folder:<путь>"` агент SHALL обращаться к хабу теми же запросами, что по HTTP, через файлы в `<путь>`: на каждый вызов записать `req/.json` = `{id, method, path, body, authorization, deadline}` (`id` — `uuid4().hex`, `authorization` — `Bearer `, `deadline` — момент UTC = сейчас + таймаут вызова; для `claim` таймаут не меньше 45 с) через временный файл с именем на точку и переименование, затем опрашивать `resp/.json` каждые 0,1 с. Полученный ответ агент SHALL удалить; `status` 2xx — вернуть разобранное тело, иной — выбросить `HttpError(status, body)` так же, как HTTP-клиент. Если к `deadline` ответа нет, агент SHALL удалить свой `req/.json` (если он ещё там) и выбросить `ConnectionError`. Нечитаемый JSON ответа SHALL перечитываться до 2 с, затем `ConnectionError`. Чужие файлы в `resp/` агент MUST NOT трогать. Цикл выполнения, журнал, повторы отчёта и восстановление SHALL работать без изменений. #### Scenario: Ответ хаба через папку - **WHEN** агент вызвал `settings`, реле положило `resp/.json` со `status = 200` - **THEN** агент получает настройки, файл ответа удалён #### Scenario: Реле не отвечает - **WHEN** агент вызвал `claim`, а ответа нет 45 с - **THEN** `req/.json` удалён агентом, выброшен `ConnectionError`; цикл пишет «хаб недоступен» и повторяет `claim` через `poll_seconds`, как при недоступном хабе по сети #### Scenario: Код ошибки хаба через папку - **WHEN** агент отправил `result` по команде, которой в хабе нет, реле положило ответ со `status = 404` - **THEN** агент получает `HttpError(404)` и считает команду закрытой, как по HTTP