ADR 0111 — Конвертация execution_mode существующего проекта: отказ при активном dev agent, перемещение permissions.json, вывод из эксплуатации строки контейнера
- Статус: Принят
- Дата: 2026-08-23
- Контекст: закрывает пункт backlog «конвертировать
execution_modeСУЩЕСТВУЮЩЕГО проекта» (Волна 2 пакета runner/execution_mode,docs/explanation/backlog.md), исправляет пункт 4 ADR 0104 - Пересматривает (без правки): ADR 0104 (утверждение пункта 4), ADR 0072, ADR 0081
Контекст
ADR 0104 (Волна 1 того же пакета) ввёл execution_mode с тремя значениями
(container/mounted/runner) при СОЗДАНИИ проекта и в пункте 4 утверждал
как последствие — не то, что он спроектировал, — что конвертация между тремя
режимами в существующем проекте «перестаёт требовать пересоздания проекта».
Эта фраза была написана до того, как кто-либо проследил, что на самом деле
указывает на старую область. Сама реализация Волны 1 обнаружила, что она
ошибочна, и зафиксировала исправление в backlog.md, а не правкой принятого
ADR: UpdateProjectDto по-прежнему намеренно исключает
executionMode/workspacePath
(apps/api/src/interfaces/http/iam/dto/update-project.dto.ts), потому что на
старую пару (режим, путь) указывают три вещи, и ни одна из них не следует за
простым UPDATE колонки:
permissions.json, физически записанный вprojectScopeRoot(project)— другое место файловой системы для каждого режима (fs-permissions-file-store.ts);project_containers(ADR 0081), чья единственная допустимая машина состояний предполагаетexecution_mode = 'container'—RegistrarTransicaoDeContainerUseCaseотказывается (400) трогать строку в ином случае;- работающий
Engine.Dev.DevAgentServer, который захватываетworkspace_rootОДИН раз, в состоянии GenServer, в момент создания worktree (Engine.Actions.Workspace.ensure_remoto/2→workspace_dir/1,2→Engine.Projects.Project.workspace_dir_name/1) — близнецprojectScopeRootв engine. Переключение колонки базы под живым агентом его не перемещает; только СЛЕДУЮЩИЙ worktree разрешается против нового места.
Этот ADR проектирует конвертацию, которую Волна 1 отложила, и закрывает
исправление, зафиксированное в backlog.md, — утверждение пункта 4 ADR 0104
снова становится ПРАВДОЙ, через механизм ниже, а не правкой принятого текста.
Решение
1. Выделенный маршрут, а не ослабленный UpdateProjectDto
PUT /projects/:projectId/execution-mode (роль maintainer — та же
серьёзность, которую CLAUDE.md уже приписывает изменению
max_parallel/бюджета области: «изменить потолок — значит решить, сколько
продукт тратит, не спрашивая»), поддерживаемый
ConvertProjectExecutionModeUseCase. UpdateProjectDto по-прежнему
исключает оба поля, и его комментарий теперь указывает сюда, а не только
описывает пробел. Общий PATCH — это запись колонки; конвертация — это
ОРКЕСТРАЦИЯ — проверить новую пару (режим, путь), отказать при активном dev
agent, переместить permissions.json, вывести из эксплуатации строку
жизненного цикла контейнера при уходе из container и только затем записать
колонку, — и смешивание этого в общем DTO заставило бы простой случай
(переименование проекта) нести сложность редкого.
2. RN-447 — отказывать (409), пока любой dev agent проекта не простаивает; никогда не осушать/мигрировать
Риск конкурентности реален, а не теоретичен: DevAgentServer не разрешает
место worktree повторно, поэтому конвертация под живым агентом оставила бы его
писать в область, которую permissions.json и политика пути терминала
(ADR 0055) уже покинули. Проверка читает engine.dev_agent_states напрямую —
между схемами, в одной физической базе, тот же прецедент, что
onlineAgentCount из RN-409, — ища status <> 'idle' во ВСЕХ сессиях
проекта (не в event log одной сессии, в отличие от
GetSessionPendingWorkUseCase, который ограничен сессией и потребовал бы
опрашивать каждую сессию, которая когда-либо была у проекта). idle_tripped
считается активным намеренно, в отличие от подсчёта онлайн в RN-409: агент с
разомкнутым автоматом всё ещё держит захваченный workspace_root и нуждается
в человеке для разблокировки, даже не будучи «онлайн» по определению RN-409 —
два вопроса («следит ли кто-то за работой этого агента» против «есть ли у
кого-то указатель области, который устареет») разные, и исключение
idle_tripped из RN-409 сюда не переносится.
Решение — ОТКАЗАТЬ и объяснить, никогда не осушать и не принуждать к миграции живого агента — тот же образец «отказать и научить», что в сообщениях проверки диска RN-088/RN-422. Построение пути миграции для агента в полёте означало бы либо убить его процесс посреди задачи (потеряв ещё не закоммиченное), либо научить его разрешать область заново на лету, чего не делает и ни одна другая часть продукта.
3. RN-448 — permissions.json ПЕРЕМЕЩАЕТСЯ, содержимое никогда не меняется
PermissionsFileStore получил move(from, to): читает файл в СТАРОМ
projectScopeRoot, пишет ТО ЖЕ содержимое в НОВЫЙ и затем, по возможности,
удаляет старый файл. Содержимое (шаблоны allow/deny/ask) не несёт внутри
ни пути, ни execution_mode — это чистая история политики («эта точная команда
была всегда-одобрена»), так что переписывать нечего, только где живёт
физический файл. Проверено прослеживанием projectScopeRoot от начала до
конца (project-workspaces-root.ts): для mounted/runner он возвращает сам
workspacePath, трактуемый как путь внутри файловой системы КОНТЕЙНЕРА API —
для runner это верно уже сегодня, даже без конвертации, поскольку
bind-mount нет; файл физически живёт по пути, совпадающему с реальной папкой
пользователя лишь как СТРОКА и отсоединённому от неё. Конвертация не меняет
этого существующего свойства, лишь перемещает файл согласованно с ним — никакой
особой обработки для runner, кроме использования move(), как для любой
другой пары.
4. RN-449 — уход из container выводит из эксплуатации строку project_containers до того, как колонка переключится
Если execution_mode сегодня container, а назначение — нет,
RegistrarTransicaoDeContainerUseCase переводит строку жизненного цикла
контейнера (когда она есть) в removed — проходя сначала через stopped,
если она running, поскольку у машины состояний container-lifecycle.ts нет
прямого ребра running -> removed, — ДО того, как изменится колонка
execution_mode проекта. Порядок важен: сам
RegistrarTransicaoDeContainerUseCase отказывает (400) в любом переходе в
проекте, у которого сегодня не execution_mode = 'container', так что вызов
его после переключения всегда падал бы. Вход в container ИЗ
mounted/runner делает противоположное — ничего: никакого автоматического
provisioning — затвор образа Arquiteto (RN-105) и обычный жизненный цикл
контейнера действуют с этого момента, ровно как для любого проекта
container, никогда не короткий путь, пропускающий затвор, потому что у
проекта «уже был где-то код».
5. RN-450 — workspaceVerifiedAt возвращается в null при каждой реальной конвертации
Он что-то значит только для runner (RN-423): штамп, доказывающий, что
подключённый runner подтвердил путь на реальном хосте. Любая конвертация —
включая ту, что возвращается в runner с тем, что выглядит как тот же путь, —
вынуждает НОВОЕ подтверждение, потому что старый штамп удостоверяет проверку
пути, произошедшую при ДРУГОЙ паре (режим, путь); перенос его утверждал бы
подтверждение, которого для нового состояния никогда не было.
6. Та же (режим, путь), что сегодня, — no-op, по замыслу, а не случайно
Если запрошенная пара разрешается ровно в то, что у проекта уже есть, use case
возвращает проект нетронутым: никакой проверки dev agent, никакого move
permissions.json, никакого сброса workspaceVerifiedAt. Повторная отправка
той же формы не должна стоить проверки живых агентов, ни молча
разверифицировать уже подтверждённый проект runner.
Последствия
Чего этот ADR НЕ перемещает: код, который уже на диске. Конвертация
никогда не копирует файлы между старым и новым корнем области. Это намеренно
не пробел, который реализация пытается замаскировать, — это следствие того,
как engine уже выводит workspace (Engine.Actions.Workspace.ensure!/4,
apps/engine/lib/engine/actions/workspace.ex): каталог workspace, ещё не
помеченный как готовый, получает git init + fetch из BARE-репозитория
проекта (настоящего источника истины) при следующем создании worktree, каким бы
ни была пара (режим, путь), на которую теперь указывает колонка.
Закоммиченная работа в безопасности — она живёт в bare-репозитории и снова
извлекается в новое место. Незакоммиченные изменения в брошенном старом
worktree не мигрируются и не удаляются — они остаются осиротевшими на месте,
по-прежнему физически на диске, в старом корне области (восстановимые
вручную, никогда автоматически), но невидимые продукту с момента
конвертации. Охрана RN-447 снижает этот риск (никакой конвертации, пока агент
активно working), но не устраняет его: dev agent в idle между задачами
всё ещё может сидеть на незакоммиченном diff, и ничто здесь не проверяет
чистоту рабочего дерева перед разрешением конвертации.
Чего этот ADR не строит: способа конвертировать ТОТ ЖЕ проект в режим, к
которому он уже полностью эквивалентен, не выставляя отметку «изменилось», —
не нужно, см. Решение 6. Он не трогает UpdateProjectDto, не добавляет
миграцию (ProjectRepository.update уже принимал эти три колонки начиная с
ConfirmProjectWorkspaceUseCase из ADR 0104/RN-423) и не меняет политику
области пути терминала (ADR 0055) или затвор образа (RN-105) сверх того, что
уже говорит Решение 4.