Andrey Epifancev ca231ba2ae docs(openspec): архив agent-self-update (#22)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 22:58:46 +04:00

250 lines
26 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.

# 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, агента не запускает