149 lines
18 KiB
Markdown
149 lines
18 KiB
Markdown
## 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`, — рулинг R-9 агента: одна подсказка не глушит другую).
|
||
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 | проверено по коду фейков | нет |
|