Перейти к основному содержимому

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.