Andrey Epifancev d427bf3584 docs(openspec): agent-v9-hardening — спека запускателя v2 и агента v9 (#26)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 23:52:00 +04:00

149 lines
18 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.

## 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 | проверено по коду фейков | нет |