docs(openspec): agent-v9-hardening — спека запускателя v2 и агента v9 (#26)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
3d13f2d190
commit
d427bf3584
2
openspec/changes/agent-v9-hardening/.openspec.yaml
Normal file
2
openspec/changes/agent-v9-hardening/.openspec.yaml
Normal file
@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-10-14
|
||||
148
openspec/changes/agent-v9-hardening/design.md
Normal file
148
openspec/changes/agent-v9-hardening/design.md
Normal file
@ -0,0 +1,148 @@
|
||||
## Context
|
||||
|
||||
Агент v8 (= код v5) работает на виртуалке под `run.py` v1 (launchd, KeepAlive), транспорт — общая папка UTM.
|
||||
Код: хаб `main` 3d13f2d, агент `main` d364cee. Issues: aep/jira-hub#26 (эта дельта спеки), aep/jira-agent#23 (код).
|
||||
Источник находок: aep/jira-agent#22 (M-3…M-7, M-10 финального ревью `agent-self-update`), #18 (M-2, M-3, M-5, M-6
|
||||
пакета C), #4 (M-10 — точность фейков).
|
||||
|
||||
Найдено при чтении кода:
|
||||
- `run.py` решает «пробный период или нет» только по `update.json.state == "trial"` (`Launcher.run`), sha256 файла
|
||||
`agent.py` не сверяет. `update.json.sha256` равен sha256 файла версии: агент пишет в `agent.py.new` ровно байты
|
||||
`content`, sha256 которых проверен.
|
||||
- `apply_update`: копия `agent.py` → `agent.py.prev.tmp` → `agent.py.prev`, затем `os.replace(new, agent)`, затем
|
||||
`write_state(trial)`. Между двумя последними шагами `update.json = staged`, `agent.py.new` уже нет: при перезапуске
|
||||
`recover_files` ничего не делает, новая версия работает без пробного периода, `confirm()` агента (только из `trial`)
|
||||
`ok` не пишет — `staged` навсегда (M-5).
|
||||
- `rollback`: `os.replace(prev, agent)`, затем `write_state(rolled_back)`. Падение между ними оставляет `trial` при
|
||||
прежней версии в `agent.py` и без `agent.py.prev`: каждые 10 минут SIGTERM, «откат невозможен», перезапуск (M-3).
|
||||
- `wait_child` после таймаута сразу возвращает «истёк»; ответ `claim`, пришедший за секунду до SIGTERM, успевает
|
||||
записать `ok`, но откат всё равно делается (M-4).
|
||||
- `cmd_run` при занятой блокировке журнала возвращает 1 — как любое падение; в `trial` это откат здоровой версии,
|
||||
если журнал держит осиротевший агент (M-7).
|
||||
- `Updater.prepare` пишет `agent.py.new.tmp`, делает `chmod`, `os.replace`, запускает `selftest` — `OSError`
|
||||
(диск полон, нет прав) не ловится: трейсбек, код 1, перезапуск, снова `prepare` (M-6). В `run.py` `OSError` в
|
||||
`apply_update` роняет запускатель; launchd поднимает его, `recover_files` снова вызывает `apply_update` — цикл.
|
||||
- `Updater.check` делает `str(release.get("sha256"))`: `None` превращается в `"None"`, подготовка каждый раз получает
|
||||
«публикация сменилась между запросами» — временный сбой каждые 5 минут навсегда (M-10). Хаб сам такого не отдаёт
|
||||
(`release.current` возвращает `str(m["sha256"])`), но агент не должен зависеть от этого.
|
||||
- `restore_pace` при времени журнала в будущем пишет предупреждение и затем «последний вызов Jira не позже <будущее>»
|
||||
(M-2 #18).
|
||||
- `do_refresh` вызывает `hub.known_keys()` и `hub.state()`; их `ConnectionError` уходит в `Executor.run` как обрыв
|
||||
связи с Jira: `retryable`, лог «обрыв связи с Jira», итог «результат неизвестен (обрыв связи с Jira)…: не ответил
|
||||
через папку …» (M-6 #18).
|
||||
- Фейки: уже как в живой Jira — 400 на `key in (...)` с несуществующим ключом (`fake_jira.py:154`), ответ agile без
|
||||
`fields` у задачи без спринта (`:251`), `PUT` с `null` очищает поле. Не как в Jira: поиск дубля
|
||||
(`reporter = currentUser()`) не учитывает `project =` и `created >= -7d`; `fake_hub` отвечает на `claim` сразу, а
|
||||
настоящий хаб держит его до 30 с при пустой очереди (`api.h_claim`, `claim_wait = 30`).
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:** запускатель не откатывает здоровую версию и не оставляет новую без пробного периода ни при каком месте
|
||||
падения; нехватка места не роняет ни агента, ни запускатель; неверная публикация не даёт бесконечных сбоев; тексты
|
||||
ошибок называют настоящий источник; фейки повторяют наблюдённое поведение Jira и хаба там, где агент от него зависит.
|
||||
|
||||
**Non-Goals:** см. proposal.
|
||||
|
||||
## Decisions
|
||||
|
||||
### Запускатель `run.py` v2
|
||||
|
||||
1. **Сверка состояния (`reconcile`)** при старте вместо `recover_files`. Сначала удаляются `agent.py.new.tmp`,
|
||||
`agent.py.prev.tmp`, `update.json.tmp`; если `agent.py` нет, а `agent.py.prev` есть — восстановление, как сейчас.
|
||||
Затем по `update.json` (`S` — его `sha256`, `A` — sha256 файла `agent.py`):
|
||||
|
||||
| `state` | `A` | `agent.py.new` | Действие |
|
||||
|---|---|---|---|
|
||||
| `staged` | `= S` | любое | падение после подмены (M-5): `agent.py.new` удалить, записать `trial` |
|
||||
| `staged` | `≠ S` | есть, sha256 `= S` | подмена (решение 4) |
|
||||
| `staged` | `≠ S` | нет или sha256 `≠ S` | `agent.py.new` удалить, `rejected` «подготовленный файл потерян» |
|
||||
| `trial` | `= S` | — | без изменений (пробный период начнётся при запуске) |
|
||||
| `trial` | `≠ S` | — | устаревший пробный период (M-3): `rolled_back` «пробный период прерван: agent.py не v<to>»; отката файла нет |
|
||||
| иное или нет файла | — | есть | `agent.py.new` удалить |
|
||||
|
||||
Запись `rolled_back` (а не `ok`) при устаревшем `trial`: агент эту версию больше не ставит — так и нужно, если
|
||||
прерван откат; если `agent.py` подменили руками, вернуть версию можно публикацией с номером выше (решение владельца).
|
||||
2. **Пробный период перед каждым запуском** включается, только если `update.json.state = trial` и sha256 `agent.py`
|
||||
равен `update.json.sha256`. Иначе — та же сверка, что при старте (строка `trial`/`≠ S`). sha256 считается на каждом
|
||||
запуске агента: файл ~60 КБ, запуски редки.
|
||||
3. **Повторная проверка `ok` (M-4).** После истечения 10 минут и остановки агента (SIGTERM, через 60 с SIGKILL)
|
||||
запускатель перечитывает `update.json`: `ok` — отката нет, лог «подтверждение пришло в последний момент», та же
|
||||
версия запускается без паузы; иначе — откат, как сейчас.
|
||||
4. **Подмена с `OSError` (M-6).** Шаги подмены прежние. `OSError` до `os.replace(new, agent)` — удалить
|
||||
`agent.py.prev.tmp` и `agent.py.new`, записать `rejected` «подмена не удалась: <ошибка>», запустить текущий
|
||||
`agent.py`. `OSError` при записи `trial` после замены — немедленно вернуть `agent.py.prev` на место (переименование,
|
||||
места не требует), попытаться записать `rejected`; если не удалось — при следующем старте сверка увидит
|
||||
`staged` / `≠ S` / нет `agent.py.new` → `rejected`. `rejected` навсегда для этой sha256, повтора нет: у запускателя
|
||||
нет таймера, повтор ушёл бы в цикл; лечится освобождением места и публикацией v+1 (решение владельца).
|
||||
5. **Запись `update.json` в запускателе** не роняет его: `OSError` — лог «не удалось записать update.json: …»,
|
||||
работа продолжается. Откат (`os.replace(prev, agent)`) места не требует.
|
||||
6. **Код 73 (M-7).** Выход агента с 73 — «журнал занят другим процессом»: не сбой версии. Отката нет даже в `trial`,
|
||||
лог «агент уже запущен другим процессом (журнал занят), повтор через N с», перезапуск с обычной нарастающей паузой
|
||||
5…300 с. Чужие процессы запускатель не трогает. В `trial` пробный период начинается заново при каждом запуске.
|
||||
7. `LAUNCHER_VERSION = 2`; переменная `JIRA_AGENT_LAUNCHER = 2` уходит хабу в `claim` как `launcher_version`.
|
||||
|
||||
### Агент v9
|
||||
|
||||
8. **Код 73.** `cmd_run` при занятой блокировке журнала возвращает `EXIT_LOCKED = 73` (`EX_CANTCREAT`, рядом с 75
|
||||
`EX_TEMPFAIL`) с прежним текстом «агент уже запущен…». `prune` при занятой блокировке — по-прежнему 1: его
|
||||
запускатель не вызывает.
|
||||
9. **`OSError` в подготовке.** В `Updater.prepare` запись `agent.py.new.tmp`, `os.replace`, запуск `selftest` и запись
|
||||
`staged` обёрнуты: `OSError` — временный сбой (`_transient`: удалить `agent.py.new` и `.tmp`, повтор не раньше 5 мин),
|
||||
лог «обновление до v<N> отложено на 5 мин: файлы: <ошибка>». `_drop_new` терпит любой `OSError`. Запись `rejected`
|
||||
с `OSError` — только лог: агент работает дальше.
|
||||
10. **Формат `agent_release`.** `Updater.check` принимает публикацию, только если `version` — целое (не `bool`) и
|
||||
`sha256` — строка из 64 символов `[0-9a-f]`. Иначе — `False` и лог «хаб прислал неполную публикацию» не чаще раза
|
||||
в час (тот же таймер, что у подсказки про `run.py`).
|
||||
11. **Время журнала в будущем (M-2 #18).** `restore_pace` пишет одну строку «журнал: время последней записи в будущем
|
||||
(<время>) — пауза перед первой командой полная» и не пишет вторую.
|
||||
12. **Источник ошибки в `refresh` (M-6 #18).** Вызовы хаба в `do_refresh` (`known_keys`, `state`) оборачиваются:
|
||||
`ConnectionError` и `HttpError` хаба превращаются в `HubError` с текстом «хаб: <исходный текст>». `Executor.run`
|
||||
отдаёт по ней `failed` с этим текстом и признаком источника «хаб»; `retryable` — для `ConnectionError` и
|
||||
`HttpError ≥ 500` (повтор `refresh` безопасен: в Jira он не пишет). `Runner` берёт источник в текст: лог
|
||||
«обрыв связи с хабом, повтор k/3…», итог «результат неизвестен (обрыв связи с хабом), повтор безопасен: хаб: …».
|
||||
Для ошибок Jira тексты прежние.
|
||||
13. **M-5 #18.** `int(resp.get("status") or 0)` в `FolderTransport._take` заменяется на уже проверенные `status`, `body`.
|
||||
14. `AGENT_VERSION = 9`: номера 6–8 заняты живой проверкой 14.10.2026 (опубликована v8), `publish` принимает только
|
||||
версию выше опубликованной.
|
||||
|
||||
### Фейки и тесты
|
||||
|
||||
15. **`fake_jira.search_keys`** учитывает `project = <KEY>` (префикс ключа) и `created >= -<N>d` относительно часов
|
||||
фейка `now` (по умолчанию текущее время); `add()` по умолчанию ставит `created = now − 1 ч`, `POST /issue` —
|
||||
`created = now`. Тесты: задача старше 7 дней и задача чужого проекта не считаются дублем в `ensure_issue`.
|
||||
16. **`fake_hub.claim_wait`** (по умолчанию 0): при пустой очереди фейк держит `claim` до `claim_wait` секунд, как
|
||||
хаб. Тест: остановка агента во время долгого `claim` — агент выходит после ответа, без команды и без записи в журнал.
|
||||
17. **Новые тесты запускателя:** `on_sigterm` (пересылка дочернему, без нового запуска); `trial` при перезапуске
|
||||
`run.py` (sha совпадает — пробный период продолжается; не совпадает — `rolled_back`); строки таблицы решения 1;
|
||||
решение 3 (`ok` в последний момент); решение 4 (`OSError` до и после замены, через подмену `os.replace`/`open`);
|
||||
решение 6 (код 73 в `trial` — без отката). Агент: решения 8–12; будущее время журнала + остановка в паузе
|
||||
(M-3 #18: `claimed` есть, `started` нет).
|
||||
|
||||
### Выкатка
|
||||
|
||||
18. Порядок: merge PR jira-hub (спека) и jira-agent → `jira-hub agent publish src/agent.py` (v9) из основного checkout
|
||||
агента → `agent_status` показывает «обновление: ok v8 → v9» → владелец останавливает `run.py` (launchctl unload),
|
||||
копирует `run.py` v2 в `~/jira-agent-bundle`, запускает (launchctl load) → `agent_status` показывает «запускатель: v2».
|
||||
Запускатель v1 с агентом v9 работает (код 73 для него — обычное падение, как раньше с 1); запускатель v2 с агентом
|
||||
v8 — тоже (v8 выходит с 1).
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [Устаревший `trial` → `rolled_back`] ручная подмена `agent.py` блокирует автоустановку той же версии → публикация
|
||||
с номером выше.
|
||||
- [`rejected` после `OSError` в подмене] версия не ставится, пока не опубликована следующая → видно в `agent_status`
|
||||
(«v<N> отвергнута: подмена не удалась…»).
|
||||
- [sha256 на каждом запуске] при повреждённом `update.json` (нет `sha256`) пробного периода нет → строка «иное» таблицы,
|
||||
агент работает как подтверждённый; это поведение v1 при неизвестном состоянии.
|
||||
- [Код 73 от осиротевшего агента в `trial`] новая версия не стартует, пока жив чужой процесс; пробный период каждый раз
|
||||
заново → в логе запускателя видно «журнал занят».
|
||||
|
||||
## Decisions made without discussion
|
||||
|
||||
| Маркер | Дата | Решение | Обоснование | Цена ошибки |
|
||||
|---|---|---|---|---|
|
||||
| R-1 (design #6, #8) | 2026-10-14 | Код «журнал занят» — 73 (`EX_CANTCREAT`) | рядом с 75 (`EX_TEMPFAIL`) из sysexits; не пересекается с 0/1/2/3/130 | смена кода — правка двух файлов и спеки |
|
||||
| R-2 (design #12) | 2026-10-14 | Подлежащее «хаб: » добавляется при обёртке в `refresh`, а не в тексте `FolderTransport` | `check` уже печатает «хаб: НЕДОСТУПЕН — <текст>»; «хаб» в тексте транспорта дал бы «хаб: НЕДОСТУПЕН — хаб не ответил» | нет |
|
||||
| R-3 (design #10) | 2026-10-14 | Неверный формат `agent_release` — молча игнорировать с логом раз в час, без `rejected` | версия неизвестна — записать `rejected` не для чего; хаб такого не отдаёт | лишний час до заметной строки в логе |
|
||||
| R-4 (design #3) | 2026-10-14 | `ok` в последний момент — перезапуск без паузы | агента остановил запускатель, а не сбой | нет |
|
||||
| R-5 (design #15, #16) | 2026-10-14 | Фейки: только `project`, `created` и долгий `claim`; 400 на `key in`, agile без `fields`, `PUT null` уже как в Jira | проверено по коду фейков | нет |
|
||||
58
openspec/changes/agent-v9-hardening/proposal.md
Normal file
58
openspec/changes/agent-v9-hardening/proposal.md
Normal file
@ -0,0 +1,58 @@
|
||||
## Why
|
||||
|
||||
Самообновление агента работает с 14.10.2026 (v5 → v8 на виртуалке, живая проверка пройдена), но финальное ревью
|
||||
change'а `agent-self-update` оставило отложенные находки в запускателе `run.py` и `Updater` (aep/jira-agent#22):
|
||||
`run.py` верит `update.json` на слово, и после падения запускателя посреди подмены или отката может откатить
|
||||
здоровую версию, оставить новую версию без пробного периода или уйти в цикл «10 минут работы → SIGTERM».
|
||||
Полный диск роняет агента трейсбеком и запускатель — в цикл падений. Рядом лежат мелкие находки гигиены
|
||||
(aep/jira-agent#18) и неточность фейков Jira/хаба в тестах агента (aep/jira-agent#4, M-10): ошибку `ensure_sprint`
|
||||
(`KeyError: 'fields'`) 13.10.2026 нашли только на живой Jira.
|
||||
|
||||
Решения владельца 14.10.2026 (brainstorming): чинить всё одним пакетом — агент v9 (через самообновление) и
|
||||
запускатель v2 (владелец переставляет вручную); фейки подтянуть к наблюдённому поведению Jira, а не отклонять.
|
||||
|
||||
## What Changes
|
||||
|
||||
- **Запускатель `run.py` v2** (`LAUNCHER_VERSION = 2`):
|
||||
- сверка при старте: `update.json` сравнивается с sha256 файла `agent.py` и наличием `agent.py.new`; устаревший
|
||||
`trial` (агент не та версия) → `rolled_back` без отката; `staged` при уже подменённом `agent.py` → `trial`;
|
||||
`staged` без годного `agent.py.new` → `rejected`; удаление `agent.py.new.tmp`, `agent.py.prev.tmp`, `update.json.tmp`;
|
||||
- пробный период включается, только если sha256 `agent.py` равен sha256 версии в `update.json`;
|
||||
- после истечения пробного периода и остановки агента `update.json` перечитывается: `ok` — отката нет;
|
||||
- код выхода агента 73 («другой процесс держит журнал») — не сбой версии: без отката, перезапуск с паузой;
|
||||
- `OSError` при подмене — `rejected` «подмена не удалась», работает текущая версия; запускатель не падает.
|
||||
- **Агент v9** (`AGENT_VERSION = 9`):
|
||||
- `run` при занятой блокировке журнала выходит с кодом 73 (было 1);
|
||||
- `OSError` при подготовке версии — временный сбой (повтор через 5 мин), не трейсбек;
|
||||
- `agent_release` неверного формата (версия не целое, sha256 не 64 hex-символа) игнорируется, в логе не чаще раза в час;
|
||||
- время журнала в будущем — одна строка лога вместо двух противоречивых;
|
||||
- ошибки хаба внутри `refresh` помечаются источником «хаб» (лог и `error` отчёта), `FolderTransport` пишет «хаб не
|
||||
ответил через папку…»;
|
||||
- тесты: будущее время журнала + остановка в паузе, `on_sigterm` запускателя, `trial` при перезапуске `run.py`;
|
||||
- фейки: `project =` и `created >= -Nd` в поиске, очистка Epic Link `null`, 400 на `key in (...)` с несуществующим
|
||||
ключом, долгий `claim` хаба.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
<!-- нет -->
|
||||
|
||||
### Modified Capabilities
|
||||
- `agent`: код выхода при занятом журнале; источник ошибки хаба в `refresh`; лог при времени журнала в будущем;
|
||||
самообновление — `OSError` и формат `agent_release`; запускатель — сверка состояния с файлом, повторная проверка
|
||||
`ok`, код 73, `OSError` при подмене, очистка временных файлов.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- код хаба не меняется: `agent_release.sha256` у хаба всегда строка (`release.current`), формат проверяет агент;
|
||||
- самообновление `run.py` — по-прежнему вручную;
|
||||
- повтор подмены в запускателе после `OSError` (у запускателя нет таймера; лечится публикацией v+1, см. design R-2);
|
||||
- новые состояния `update.json` — используются существующие `staged`, `trial`, `ok`, `rolled_back`, `rejected`.
|
||||
|
||||
## Impact
|
||||
|
||||
- jira-hub: только `openspec/specs/agent/spec.md` (через этот change). Код, схема БД, API, MCP — без изменений.
|
||||
- jira-agent (aep/jira-agent#23): `src/agent.py` (v9), `src/run.py` (v2), `tests/` (фейки и новые тесты), README,
|
||||
ARCHITECTURE.md, CLAUDE.md.
|
||||
- Выкатка, порядок важен: сначала `jira-hub agent publish` v9 (самообновление с v8), затем владелец вручную ставит
|
||||
`run.py` v2. Запускатель v2 с агентом v8 работает, но код 73 понимает только от v9.
|
||||
234
openspec/changes/agent-v9-hardening/specs/agent/spec.md
Normal file
234
openspec/changes/agent-v9-hardening/specs/agent/spec.md
Normal file
@ -0,0 +1,234 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Цикл выполнения
|
||||
Агент SHALL работать одним процессом: `claim` к хабу (долгий запрос) → при получении команды: досыпать до
|
||||
`pause_seconds` от последнего вызова Jira → запись «started» в журнал → выполнение по контракту `commands` → запись
|
||||
«finished» с результатом → отчёт хабу, повторяемый до подтверждения. Одна команда в полёте: новый `claim` агент SHALL
|
||||
отправлять, только когда у него нет выданной, но не завершённой и не отчитанной команды (на этом хаб строит повторную
|
||||
выдачу, см. `commands`). Один процесс на журнал: `run` SHALL брать исключительную блокировку файла `<журнал>.lock` и,
|
||||
если она занята, завершаться с кодом 73 и сообщением «агент уже запущен». При изменении `settings_version` в ответе
|
||||
`claim` агент SHALL перечитать настройки до выполнения команды. Если команда упала из-за обрыва связи (с Jira или,
|
||||
в `refresh`, с хабом), лог повтора и итоговая ошибка SHALL называть источник: «обрыв связи с Jira» или «обрыв связи
|
||||
с хабом».
|
||||
|
||||
#### Scenario: Остановка между командами
|
||||
- **WHEN** получен Ctrl-C во время выполнения команды
|
||||
- **THEN** текущая команда доделывается и отчёт отправляется, затем процесс завершается
|
||||
|
||||
#### Scenario: Второй процесс с тем же журналом
|
||||
- **WHEN** агент работает, и запускается ещё один `agent.py run` с тем же `journal.jsonl`
|
||||
- **THEN** второй процесс пишет «агент уже запущен…» и завершается с кодом 73, не обращаясь к хабу и Jira
|
||||
|
||||
#### Scenario: Остановка во время долгого claim
|
||||
- **WHEN** очередь пуста, хаб держит `claim`, агент получил Ctrl-C
|
||||
- **THEN** агент дожидается ответа `claim`, завершается без команды; журнал не изменён
|
||||
|
||||
### Requirement: Ограничение темпа и 429
|
||||
Агент SHALL помнить время последнего вызова Jira (любого) и не начинать команду раньше `pause_seconds` после него. При
|
||||
старте агент SHALL принять за время последнего вызова Jira самое позднее `at` в журнале; если оно в будущем (часы
|
||||
сдвинулись назад) — текущий момент (пауза перед первой командой — полная `pause_seconds`) и одна строка лога о времени
|
||||
в будущем, без строки «последний вызов Jira не позже …»; при пустом журнале паузы перед первой командой нет. Оценка
|
||||
из журнала служит только паузе: в `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** время журнала в будущем, агент получил команду с `pause_seconds = 30` и через 5 с получил Ctrl-C
|
||||
- **THEN** в журнале `claimed` этой команды без `started`, в 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`. Ошибка хаба при запросе известных ключей или отправке пачки SHALL попадать в
|
||||
`error` с префиксом «хаб: »; недоступность хаба и его 5xx повторяются, как обрыв связи, с источником «хаб».
|
||||
|
||||
#### Scenario: Полная синхронизация
|
||||
- **WHEN** `refresh {jql: "project = AURORA AND labels = AWPCA"}` при 180 задачах
|
||||
- **THEN** 2 вызова `search` + 1 на соседей; `sync_run.issues_count = 180 + соседи`
|
||||
|
||||
#### Scenario: Хаб не ответил при отправке состояния
|
||||
- **WHEN** `refresh` через папку, реле не ответило на отправку пачки ни в одной из 4 попыток
|
||||
- **THEN** отчёт `failed` с ошибкой «результат неизвестен (обрыв связи с хабом), повтор безопасен: хаб: не ответил через
|
||||
папку …»; в логе повторов — «обрыв связи с хабом»
|
||||
|
||||
### 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`. Публикацию, в которой `version` не целое число или `sha256` не строка из 64
|
||||
символов `[0-9a-f]`, агент SHALL игнорировать и писать об этом в лог не чаще раза в час. Версию с теми же `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 символами
|
||||
вывода. Ошибка файловой системы (`OSError`) на любом шаге записи или запуска SHALL быть временным сбоем. Годную версию
|
||||
агент 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** запись `agent.py.new.tmp` завершилась `OSError` (нет места)
|
||||
- **THEN** временного файла и `agent.py.new` нет, `update.json` не изменён, агент работает дальше без трейсбека;
|
||||
следующая попытка — не раньше чем через 5 минут
|
||||
|
||||
#### Scenario: Неполная публикация
|
||||
- **WHEN** `claim` вернул `agent_release = {version: 9, sha256: null}`
|
||||
- **THEN** агент не запрашивает `GET /agent/release`, не пишет `update.json`; в логе «хаб прислал неполную публикацию»
|
||||
не чаще раза в час
|
||||
|
||||
#### 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` (2) и унаследованными stdout/stderr. Запускатель SHALL брать
|
||||
неблокирующую блокировку `run.py.lock`; занята — «запускатель уже работает», код 1. SIGINT при живом дочернем процессе
|
||||
запускатель SHALL только запоминать как остановку человеком (Ctrl-C получает и агент), без дочернего процесса (пауза
|
||||
перед перезапуском) — завершаться; SIGTERM — пересылать дочернему процессу. После сигнала остановки запускатель MUST NOT
|
||||
запускать агента снова. Ошибка записи `update.json` (`OSError`) MUST NOT завершать запускатель: он пишет её в лог и
|
||||
продолжает.
|
||||
|
||||
При старте запускатель SHALL удалять `agent.py.new.tmp`, `agent.py.prev.tmp`, `update.json.tmp`, восстанавливать
|
||||
`agent.py` из `agent.py.prev`, если первого нет, и сверять `update.json` с файлом `agent.py` (`S` — `sha256` из
|
||||
`update.json`, `A` — sha256 файла `agent.py`):
|
||||
- `staged` и `A = S` — подмена уже сделана: `agent.py.new` удалить, записать `trial`;
|
||||
- `staged`, `A ≠ S`, `agent.py.new` есть и его sha256 `= S` — подмена (ниже);
|
||||
- `staged`, `A ≠ S`, `agent.py.new` нет или его sha256 `≠ S` — `agent.py.new` удалить, `rejected` «подготовленный файл
|
||||
потерян»;
|
||||
- `trial` и `A ≠ S` — `rolled_back` «пробный период прерван: agent.py не v<to_version>», файлы не трогаются;
|
||||
- в остальных случаях `agent.py.new` (если есть) удаляется.
|
||||
|
||||
Пробный период SHALL действовать для запуска агента, только если `update.json` = `trial` и sha256 `agent.py` равен
|
||||
`update.json.sha256`; при несовпадении запускатель SHALL записать `rolled_back`, как при старте, и запустить агента без
|
||||
пробного периода.
|
||||
|
||||
По коду выхода дочернего процесса:
|
||||
- `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 —
|
||||
после подмены запускатель завершается, пробный период начнётся при следующем старте. `OSError` до замены
|
||||
`agent.py` — удалить `agent.py.new` и `agent.py.prev.tmp`, `rejected` «подмена не удалась: <ошибка>», запуск текущей
|
||||
версии; `OSError` при записи `trial` после замены — вернуть `agent.py.prev` на место `agent.py` и записать
|
||||
`rejected` «подмена не удалась: <ошибка>»;
|
||||
- `73` — журнал занят другим процессом: не сбой версии, отката нет и в пробном периоде; лог «агент уже запущен другим
|
||||
процессом (журнал занят)», перезапуск с паузой, как после падения;
|
||||
- иной код при действующем пробном периоде — откат; иначе — перезапуск той же версии с паузой 5 с, удваиваемой до
|
||||
300 с при каждом следующем падении и сбрасываемой после 10 минут работы.
|
||||
|
||||
Пока действует пробный период, запускатель SHALL ждать состояния `ok` не дольше 10 минут; по истечении — SIGTERM,
|
||||
через 60 с SIGKILL; затем запускатель SHALL перечитать `update.json`: `ok` — отката нет, та же версия запускается без
|
||||
паузы; иначе — откат. Откат: `agent.py.prev` возвращается на место `agent.py`, `update.json` = `rolled_back` с причиной
|
||||
(«код <N> через <S> с» или «не вышла на связь за 10 мин»), прежняя версия запускается без паузы. Файл `agent.py` MUST
|
||||
существовать на каждом шаге подмены и отката.
|
||||
|
||||
#### 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, sha256 `agent.py` совпадает, дочерний процесс вышел с кодом 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, перечитывает `update.json` и, если там
|
||||
не `ok`, делает откат с причиной «не вышла на связь за 10 мин»
|
||||
|
||||
#### Scenario: Подтверждение в последний момент
|
||||
- **WHEN** пробный период истёк, запускатель послал SIGTERM, а агент до выхода записал `ok`
|
||||
- **THEN** отката нет; `agent.py` — новая версия, она запускается снова без паузы
|
||||
|
||||
#### Scenario: Новая версия сама вышла с кодом 0
|
||||
- **WHEN** `update.json` = `trial` для v7, дочерний процесс вышел с кодом 0, сигналов запускатель не получал
|
||||
- **THEN** откат на v6 с причиной «код 0 через <S> с», как при падении
|
||||
|
||||
#### Scenario: Падение после подтверждения
|
||||
- **WHEN** `update.json` = `ok`, агент дважды подряд упал с кодом 1, проработав меньше минуты
|
||||
- **THEN** запускатель перезапускает ту же версию через 5 с, затем через 10 с; отката нет
|
||||
|
||||
#### Scenario: Падение запускателя между подменой и записью trial
|
||||
- **WHEN** `update.json` = `staged` для v9 с sha256 `S`, `agent.py` уже v9 (sha256 `S`), `agent.py.new` нет; запускатель
|
||||
стартует
|
||||
- **THEN** `update.json` = `trial` для v9, запущен v9 в пробном периоде; после его первого `claim` — `ok`
|
||||
|
||||
#### Scenario: Устаревший пробный период
|
||||
- **WHEN** `update.json` = `trial` для v9 с sha256 `S`, а `agent.py` — v8 (откат был прерван); запускатель стартует
|
||||
- **THEN** `update.json` = `rolled_back` «пробный период прерван: agent.py не v9», v8 запущен без пробного периода и
|
||||
через 10 минут не останавливается; v9 с sha256 `S` агент больше не ставит
|
||||
|
||||
#### Scenario: Журнал занят в пробном периоде
|
||||
- **WHEN** `update.json` = `trial` для v9, sha256 совпадает, дочерний v9 вышел с кодом 73 (журнал держит осиротевший
|
||||
агент)
|
||||
- **THEN** отката нет, `agent.py` — v9, `update.json` = `trial`; лог «журнал занят», перезапуск через 5 с
|
||||
|
||||
#### Scenario: Диск полон при подмене
|
||||
- **WHEN** дочерний процесс вышел с 75, копирование `agent.py` в `agent.py.prev.tmp` завершилось `OSError`
|
||||
- **THEN** `agent.py` — прежняя версия, `agent.py.new` и `agent.py.prev.tmp` удалены, `update.json` = `rejected`
|
||||
«подмена не удалась: …»; запущена прежняя версия; запускатель работает
|
||||
|
||||
#### Scenario: Остановка в терминале
|
||||
- **WHEN** `python3 run.py` работает в терминале, нажат Ctrl-C
|
||||
- **THEN** агент доделывает команду и выходит с кодом 0, запускатель завершается с кодом 0; повторный Ctrl-C — агент
|
||||
выходит с 130, запускатель тоже
|
||||
|
||||
#### Scenario: SIGTERM запускателю
|
||||
- **WHEN** `run.py` работает под launchd, `launchctl unload` посылает ему SIGTERM
|
||||
- **THEN** запускатель пересылает SIGTERM агенту, дожидается его выхода и завершается; нового запуска агента нет
|
||||
|
||||
#### Scenario: Второй запускатель
|
||||
- **WHEN** `run.py` работает, и запускается ещё один `run.py` в том же каталоге
|
||||
- **THEN** второй пишет «запускатель уже работает» и завершается с кодом 1, агента не запускает
|
||||
Loading…
x
Reference in New Issue
Block a user