Кто владеет завершением обновления из исходников
Граница фаз
Процесс команды отвечает за допуск, блокировку обновления и время жизни вывода,
опись до обновления, снимки всех профилей, приостановку шлюзов, выбор ревизии
в Git, stash и восстановление, защиты по синтаксису и HEAD, а также за
скачивание ZIP, его размещение, повторную проверку на грязное дерево, пересадку
релиза и подмену. Транспорт завершения он импортирует до подмены кода. Как
только финальное дерево выбрано (включая слияние с апстримом), пути Git,
повтора «уже актуально» и ZIP отправляют один версионированный JSON-запрос в
update_completion.py из этого дерева. Ни один закэшированный модуль
приложения в процессе команды не выгружается и не перезагружается.
Запрос несёт канонические пути исходников и домашнего каталога, выбор десктопного продукта, режимы interactive и gateway, версию до обновления, идентификаторы снимков активного и соседних профилей, сериализованный план рантайма, идентичность и данные открытой квитанции и токен приостановленного Windows. Внутри только данные, никогда не вызываемые объекты или pickle. stdin остаётся унаследованным для интерактивных вопросов конфигурации; режим gateway сохраняет неинтерактивное поведение. Вывод дочернего процесса остаётся видимым и зеркалируется потоком вывода обновления у родителя.
Владелец на новом коде
Точка входа только на stdlib стартует на доступном Python с -I -S, чтобы не
инициализировались ни старые site-packages, ни исполняемые файлы .pth.
Приватный префикс кэша байткода отсекает устаревшие файлы кэша до любых
импортов из нового checkout. Явный путь импорта этой точки входа указывает на
новый checkout. Она вызывает новый интерфейс PM, чтобы подготовить записанное
объединение зависимостей, затем запускает выбранный Python с новым окружением
активации. Этот интерпретатор тоже стартует с отключённой инициализацией site,
после чего владелец рантайма берёт в аренду и активирует выбранное поколение до
любых импортов приложения. Код завершения приложения импортирует только этот
интерпретатор. Одна и та же идентичность квитанции/корреляции переходит через
эту границу подготовки (в том числе в результатах PM). Завершение в выбранном
Python отвечает за публикацию лаунчера, сборщики, инвалидацию кэшей,
обслуживание конфигурации, состояния и навыков всех профилей, сканирование
процессов, перезапуск парка, возобновление Windows, дедупликацию дашборда и
проверку.
Существующие алгоритмы перезапуска по видам и восстановления после прерывания остаются: кратковременные сбои супервизора и процессов реальны и без импортов из смешанных поколений. Исчезают только обходной путь с очисткой и перезагрузкой модулей и независимые хвостовые композиции повтора и ZIP. Статус выхода шлюза записывается до того, как перезапуск может завершить cgroup апдейтера. Проверка публикует финальную квитанцию.
После точки фиксации обновление ничем не проваливается
Как только дерево сдвинулось, hermes update завершается с кодом 0, если только сам код
не был откачен. Каждый шаг завершения независим: сбой публикации лаунчера, сборки
продукта (TUI, веб и десктоп пробуются по отдельности, даже если другая сборка упала),
миграции конфигурации, зачистки байткода, перезапуска и проверки шлюзов, возобновления
Windows или перехода с выведенного канала печатает строку ⚠ и дописывается в followups
квитанции как {step, reason}, а outcome квитанции остаётся "success". Обязательство
самого шага остаётся взведённым: source-completion-pending для хвоста (лаунчеры, сборка,
обслуживание, миграция конфигурации), хостовое обязательство по перезапуску парка для
шлюзов (предупреждение при старте CLI по-прежнему называет его; невыполненный перезапуск
записывает в обязательство шлюзы, бывшие до обновления, так что шлюз, умерший при загрузке,
остаётся должен, пока не начнёт обслуживать checkout, и не гасится разгрузкой для случая
без шлюзов), неподписанный отпечаток байткода для зачистки. Следующий запуск или
hermes update — в том числе по пути «Already up to date», который выполняет то же
завершение — повторяет шаг. Хост, помеченный «перезапущен» для этого коммита, но с парком,
который всё ещё работает не на этом checkout, перезапускается снова и не заходит в тупик.
Код 2 (отказ / параллельный запуск) и код 1 (ничего не зафиксировано или откат) сохраняют
свой смысл.
Синхронизация профилей работает по принципу «по мере сил»: _sync_profiles_after_update
печатает ошибку по каждому профилю и идёт дальше, поэтому такая ошибка не считается
долгом. Только синхронизация, вырвавшаяся из шага (например, через SystemExit), становится
следствием profile_sync и оставляет source-completion-pending взведённым.
Возобновление шлюза Windows пробуется один раз после точки фиксации: дочерним процессом
завершения или родителем, если должны зависимости. Его сбой — следствие windows_resume.
Собственный путь выхода команды и её atexit-страховка повторно его не запускают, а
возобновление, которое они всё ещё должны (дочерний процесс так и не ответил), сообщается
так же, а не поднимает исключение. Историческое завершение с перехватом (update_finish)
тоже записывает сбой как следствие и сохраняет свой статус выхода.
Пока прогон открыт, квитанция долговечна: begin_update_receipt пишет её как
outcome: "running" в собственный архивный файл прогона и в latest.json, а каждая граница
этапа её обновляет, так что убитое обновление оставляет собственную запись. Следующее
обновление помечает запись running, чьих процессов уже нет, как interrupted (с именем
последнего этапа) и сообщает об этом. Квитанции и tee-лог update.log разрешаются в
корневой домашний каталог (hermes_constants.get_default_hermes_root()), а не в каталог
закреплённого профиля; так же поступают и их читатели (hermes logs update, отладочный
бандл, статус обновления в дашборде, квитанции синхронизации pm).
Подготовка зависимостей в bootstrap завершения (ensure_tools_for_sync, pm.sync_venv)
тоже выполняется уже после сдвига дерева: её сбой — следствие dependencies («зависимости
пока не установлены — следующий запуск повторит»), код выхода 0 и взведённое обязательство
хвоста; подготовленный дочерний процесс, умерший без результата, — следствие completion.
Ctrl-C после точки фиксации закрывает прогон как interrupted (код 130, никогда failed) и
сообщает, что новый код уже на месте, а оставшиеся шаги за ним.
По тому же правилу работают ещё два послефиксационных канала. Маркер шлюзового /update
(.update_exit_code) сохраняет зафиксированный результат, когда перезапуск шлюза не удался
(юнит systemd, восстановление после прерывания или возобновление службы Windows): долг по
перезапуску — это следствие gateway_restart, хостовое обязательство и строки ⚠ в
пересылаемом выводе, но никогда не уведомление «failed». Хранилище квитанций, отказавшееся
принять терминальную запись, печатает ⚠ Update receipt not written, а дочерний процесс
завершения отвечает родителю коррелированной терминальной записью, которую он финализировал
в памяти, так что статус выхода остаётся 0; кодом 1 по-прежнему завершается только то, что
требует действия пользователя (локальные изменения, оставленные в stash).
Жизненный цикл родителя и сбои
Родитель ждёт и передаёт наружу точный ненулевой результат дочернего процесса (сигнал отображается в стиле оболочки как 128+сигнал); сбои шагов после точки фиксации его никогда не порождают. Просто выйти с нулём дочернему процессу недостаточно: для успеха нужен терминальный ответ с совпадающей идентичностью квитанции. Ответ возвращает изменённый токен Windows, чтобы зарегистрированное у родителя аварийное возобновление не повторяло уже сделанную работу. Обычное завершение родителя никакого обслуживания не выполняет.
Родитель хранит свою исходную квитанцию до подтверждённой финализации дочернего
процесса; если вывода дочернего процесса нет или он сбойный, квитанция остаётся
доступной существующему финализатору сбоев на границе команды. Bootstrap на
stdlib возвращает коррелированные данные о сбое PM, даже когда импорты
приложения недоступны, и нормализует выходы с отрицательным сигналом на каждой
границе процессов. На POSIX завершение владеет новой сессией и группой
процессов; отмена убивает эту группу до освобождения блокировки (в Windows —
через taskkill /T по дереву сохранённого дочернего процесса). Родитель
записывает ожидающее обязательство по парку до запуска процесса завершения, в
том числе когда подготовка не может начаться. Аварийное возобновление Windows у
родителя остаётся крайним обязательством жизненного цикла, если дочерний
процесс не может выполниться или убит. Сбойный дочерний процесс никогда не
снимает ожидающее обязательство по парку. Автоматического отката кода после
начала обслуживания нет (снимки SQLite остаются средством восстановления при
потере файлов, а не откатом).
Повреждённый код восстановления
Восстановление checkout’а при запуске требует, чтобы импортировались и блокировка
checkout’а, и раннер хранения дочерних процессов (child-custody). Если один из этих
модулей повреждён, запуск останавливается с Cannot safely repair и сохраняет маркер
прерванного обновления, а не пишет без исключения параллельных писателей. Апдейтер, уже
импортировавший здоровый код, по-прежнему может держать checkout и писать в него, даже
если его модули на диске повреждены. Дождитесь окончания любого идущего обновления.
Код восстановления лежит в дереве, которое портит убитый перенос (hermes_bootstrap.py,
hermes_cli/__init__.py, _early_recovery.py, update_lock.py, update_custody.py). Перед
записями в git каждый перенос дерева публикует эти модули восстановления, блокировки и
хранения в том виде, в каком они закоммичены на стартовом коммите переноса, в
<git dir>/hermes-update-recovery/<pre>/: вне рабочего дерева, под ключом pre из маркера
(полный id коммита и ничего другого), вместе с MANIFEST с id блоба каждого файла; файлы и
каталог синхронизируются на диск (fsync) до переименования. Когда собственный импорт
checkout’а падает при существующем маркере, выпущенный лаунчер (.hermes/bin/hermes)
запускает эту опубликованную копию, только если каждый файл совпадает с id из манифеста.
Отсутствующая, повреждённая или чужая копия (или её отсутствие у старого апдейтера) сначала
пересобирается из объектов git на pre (--no-replace-objects, записанный git или
абсолютная запись PATH, но никогда не текущий каталог) и проверяется так же. Дальше идёт
код только на stdlib плюс эта копия, тот же claim на восстановление и та же блокировка
checkout’а (живой писатель получает Not repairing the checkout now и код 1 без
traceback), затем повторный запуск из восстановленного дерева. Ограничения: у других точек
входа (python -m hermes_cli.main, хук hermes-agent) такого запасного пути нет; у лаунчера,
выпущенного до появления этого запасного пути, его тоже нет, поэтому убийство во время
первого обновления на этот код (под старым лаунчером) не покрывается; pre, в дереве
которого нет блокировки checkout’а или раннера хранения, не используется.
Изменения поведения на пути git
Намеренные отличия от апдейтера до транзакций, для заметок о выпуске:
- Запасной путь через ZIP в Windows запускается только из нетронутого checkout’а. Если git уже сделал stash, переключил или наполовину перенёс дерево, ZIP-наложение (оно заменяет каждую запись верхнего уровня) похоронило бы это состояние, поэтому прогон падает с ошибкой git.
- Точка фиксации взводит хвост завершения, перезапуск парка и маркер до записей в git. Если что-то из этого не удаётся сделать долговечным, обновление останавливается с нетронутым checkout’ом: перенос без них — это состояние «хвост так и не выполнится».
- Стартовые файлы (лаунчер и замыкание импортов
hermes_bootstrap) компилируются на целевом коммите до переноса интерпретатором, который допускаетrequires-pythonцели (uv python find, затемpython3.N). Если ни одного нет, в отказе названа версия Python, которую нужно установить. Предпроверка — одинgit cat-file --batch. - Каждый перенос называет разрешённый id коммита, а не ref, и его маркер снимается, только когда HEAD стал этим коммитом. Ветка, сдвинувшаяся во время переключения, сообщается как неудавшееся переключение. Перезапуск считается долгом для того HEAD, на который переключение реально попало.
- Fast-forward апстрима форка оценивается так же, до переноса. Сломанный коммит апстрима откатывает всё обновление.
- Маркер восстановления, чей целевой коммит исчез (gc, повторное клонирование), снимается
только поверх чистого отслеживаемого дерева на
pre. До тех пор каждый запуск предупреждает.
Историческая поверхность
Все имена, замороженные по полной достижимой истории выпущенного апдейтера,
остаются разрешимыми. Исторические хуки зависимостей сохраняют мост перехвата
на чистом stdlib: старый родитель ждёт, переносит состояние квитанции и
восстановления и никогда не возобновляет выведенный из употребления
установщик. Недавно выведенные хуки подготовки и перезагрузки модулей, явно
помеченные как незавершённые, останавливаются с ненулевым кодом и просят снова
выполнить hermes update; изготовить недостающий запрос на завершение они не
могут. Текущие вызывающие стороны Git/current/ZIP используют только
канонический транспорт завершения, а не историческую точку входа перехвата.
Композиции повтора, жившие только в ветках и не попавшие в замороженный набор,
удалены, а не прикрыты шимами. Удобная публикация ACP использует expose_cli
владельца лаунчера; историческая точка входа ACP — лишь адаптер, а не второй
писатель. Замороженный набор никогда не урезается и не заменяется покрытием
только по тегам. Новые импорты текущего пути объединяются с этой историей.
Проверка
Используйте только изолированные домашние каталоги, одноразовые Git-репозитории и фейковые адаптеры зависимостей, сборки и сервисов. Прогоните старый процесс с закэшированными несовместимыми модулями через настоящий переход Git на новый код, выполнение в выбранном Python, идентичность квитанции и передачу снимков, ненулевой или внезапный выход дочернего процесса, освобождение блокировки и возврат токена Windows. Существующие точечные тесты покрывают проверки ZIP на грязное дерево и пересадки, снимки, согласование парка, тайминги супервизора и исторические импорты. Нативный перезапуск сервисов и приёмка на Windows/macOS остаются отдельными обязательными направлениями; тестовые прогоны этой реализации не трогают ни живой пользовательский сервис, ни пользовательское состояние.
Матрица ячеек обрыва
Каждая ячейка убивает настоящий hermes update (или скрипт передачи Desktop) в одной точке
обновления, а затем проверяет, что полагается пользователю: следующий запуск hermes
работоспособен, сразу после него checkout в точности равен либо коммиту до обновления, либо
целевому (HEAD плюс каждый отслеживаемый байт: git status чист и git diff --quiet HEAD;
каждая цель правит модули, которые импортирует любой запуск), а то, что оставило мёртвое
обновление (блокировка git, .hermes-update-in-progress), не мешает следующему обновлению.
Ячейка, чьё исправление — открытый PR, оборачивает в known_failure
(tests/e2e/core/_pending_fixes.py) только свои финальные проверки, после всех остальных
проверок ячейки: она даёт XFAIL ровно на сообщении об этом пробеле, падает на любом другом и
проходит, как только исправление вливается, какой бы PR ни влили первым. Приёмочный прогон
интегрированной пачки такое послабление отвергает: HERMES_E2E_STRICT_ACCEPTANCE=<owner>
(входной параметр strict_acceptance у ci.yaml и windows-install-update-e2e.yml, например
gh workflow run windows-install-update-e2e.yml --ref <branch> -f strict_acceptance=upd-txn)
превращает в сбой каждый пробел, чья причина начинается с <owner>: (1: каждый пробел).
Точки убийства — наблюдаемые состояния (дочерний процесс git в дереве процессов по его argv,
git, удерживаемый внутри своего checkout’а фильтром при наличии index.lock, HEAD,
прочитанный из файлов ref, дочерний процесс обновления у передачи плюс её маркер), но никак
не паузы по времени.
| Ячейка | Точка убийства | Тест | Закрывающее направление |
|---|---|---|---|
Windows mid_fetch | taskkill /T /F, пока работает дочерний git fetch обновления | tests/e2e/core/windows_update/test_crash_cells.py::test_update_killed_mid_fetch_leaves_a_runnable_install | зелёная |
Windows mid_git | taskkill /T /F внутри checkout’а при fast-forward: smudge-фильтр, который тест выставляет в .git/config и .git/info/attributes установки, держит git merge, пока тот пишет hermes_constants.py (index.lock занят, два файла уже на цели, этот файл удалён) | tests/e2e/core/windows_update/test_crash_cells.py::test_update_killed_mid_git_leaves_a_runnable_install | #132361 (устаревший .git/index.lock; восстановление прерванного pull при запуске вызывало голый git, которого нет на машине с одним лишь Git из установщика); лаунчер импортировал hermes_constants раньше, чем hermes_bootstrap запускал восстановление, и удалённый модуль убивал каждый запуск первым: теперь лаунчеры доходят до восстановления раньше любого другого модуля checkout’а. Выполняется последней в сценарии |
Windows tree_moved | сразу после того, как checkout достиг цели, до завершения обновления | tests/e2e/core/windows_update/test_crash_cells.py::test_update_killed_after_the_tree_moved_leaves_a_runnable_install | зелёная на main |
Windows desktop_handoff | scripts/desktop-update/windows.ps1 и всё его дерево, пока работает его дочерний hermes update | tests/e2e/core/windows_update/test_crash_cells.py::test_desktop_handoff_killed_mid_run_leaves_a_runnable_install | зелёная на main |
Windows orphaned_update | только windows.ps1 (без /T); его hermes update продолжает работать и должен закончиться при ЖИВОМ маркере до своего выхода, после чего маркер исчезает | tests/e2e/core/windows_update/test_crash_cells.py::test_desktop_handoff_script_killed_alone_keeps_the_marker_live_until_its_update_ends | #132354 + #132365 (делегат в строке 4) |
| Точки фиксации POSIX | по направлениям | tests/e2e/core/upgrade/<area>/test_hostile_<lane>.py | направление, которому принадлежит файл |
Ячейки Windows выполняются в сценарии установки и обновления Windows
(.github/workflows/windows-install-update-e2e.yml); чтобы запустить их по требованию,
запушьте ветку wine2e-install/**. Оба набора с настоящими обновлениями обязательны для
pull request всякий раз, когда срабатывает направление e2e_upgrade классификатора
изменений (любой файл на пути обновления: scripts/ci/classify_changes.py), а набор
обновления Desktop — когда срабатывает e2e_desktop_update; в остальных случаях они
пропускаются и считаются пройденными. Путь обновления выводится, а не запоминается:
tests/ci/test_update_ci_routing.py читает каждый модуль, который импортируют точки входа
обновления (и каждый запускаемый ими скрипт сборки), и падает, пока каждый из них не получил
маршрут или не объявлен общим узлом, а ещё прогоняет вывод настоящего классификатора через
настоящие файлы workflow, чтобы выставленное направление всегда доходило до своей задачи.
Связанная страница: обновления macOS-бандла.