18 KiB
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.pyOSErrorв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
-
Сверка состояния (
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):stateAagent.py.newДействие staged= Sлюбое падение после подмены (M-5): agent.py.newудалить, записатьtrialstaged≠ Sесть, sha256 = Sподмена (решение 4) staged≠ Sнет или sha256 ≠ Sagent.py.newудалить,rejected«подготовленный файл потерян»trial= S— без изменений (пробный период начнётся при запуске) trial≠ S— устаревший пробный период (M-3): rolled_back«пробный период прерван: agent.py не v»; отката файла нетиное или нет файла — есть agent.py.newудалитьЗапись
rolled_back(а неok) при устаревшемtrial: агент эту версию больше не ставит — так и нужно, если прерван откат; еслиagent.pyподменили руками, вернуть версию можно публикацией с номером выше (решение владельца). -
Пробный период перед каждым запуском включается, только если
update.json.state = trialи sha256agent.pyравенupdate.json.sha256. Иначе — та же сверка, что при старте (строкаtrial/≠ S). sha256 считается на каждом запуске агента: файл ~60 КБ, запуски редки. -
Повторная проверка
ok(M-4). После истечения 10 минут и остановки агента (SIGTERM, через 60 с SIGKILL) запускатель перечитываетupdate.json:ok— отката нет, лог «подтверждение пришло в последний момент», та же версия запускается без паузы; иначе — откат, как сейчас. -
Подмена с
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 (решение владельца). -
Запись
update.jsonв запускателе не роняет его:OSError— лог «не удалось записать update.json: …», работа продолжается. Откат (os.replace(prev, agent)) места не требует. -
Код 73 (M-7). Выход агента с 73 — «журнал занят другим процессом»: не сбой версии. Отката нет даже в
trial, лог «агент уже запущен другим процессом (журнал занят), повтор через N с», перезапуск с обычной нарастающей паузой 5…300 с. Чужие процессы запускатель не трогает. Вtrialпробный период начинается заново при каждом запуске. -
LAUNCHER_VERSION = 2; переменнаяJIRA_AGENT_LAUNCHER = 2уходит хабу вclaimкакlauncher_version.
Агент v9
- Код 73.
cmd_runпри занятой блокировке журнала возвращаетEXIT_LOCKED = 73(EX_CANTCREAT, рядом с 75EX_TEMPFAIL) с прежним текстом «агент уже запущен…».pruneпри занятой блокировке — по-прежнему 1: его запускатель не вызывает. 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— только лог: агент работает дальше.- Формат
agent_release.Updater.checkпринимает публикацию, только еслиversion— целое (неbool) иsha256— строка из 64 символов[0-9a-f]. Иначе —Falseи лог «хаб прислал неполную публикацию» не чаще раза в час (свой таймер, отдельный от подсказки проrun.py, — рулинг R-9 агента: одна подсказка не глушит другую). - Время журнала в будущем (M-2 #18).
restore_paceпишет одну строку «журнал: время последней записи в будущем (<время>) — пауза перед первой командой полная» и не пишет вторую. - Источник ошибки в
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 тексты прежние. - M-5 #18.
int(resp.get("status") or 0)вFolderTransport._takeзаменяется на уже проверенныеstatus,body. AGENT_VERSION = 9: номера 6–8 заняты живой проверкой 14.10.2026 (опубликована v8),publishпринимает только версию выше опубликованной.
Фейки и тесты
fake_jira.search_keysучитываетproject = <KEY>(префикс ключа) иcreated >= -<N>dотносительно часов фейкаnow(по умолчанию текущее время);add()по умолчанию ставитcreated = now − 1 ч,POST /issue—created = now. Тесты: задача старше 7 дней и задача чужого проекта не считаются дублем вensure_issue.fake_hub.claim_wait(по умолчанию 0): при пустой очереди фейк держитclaimдоclaim_waitсекунд, как хаб. Тест: остановка агента во время долгогоclaim— агент выходит после ответа, без команды и без записи в журнал.- Новые тесты запускателя:
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нет).
Выкатка
- Порядок: 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.pyv2 в~/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 |
проверено по коду фейков | нет |