## 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»; отката файла нет | | иное или нет файла | — | есть | `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 отложено на 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 = ` (префикс ключа) и `created >= -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 отвергнута: подмена не удалась…»). - [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 | проверено по коду фейков | нет |