ADR 0111 — Converter o execution_mode de um projeto existente: recusar com dev agent ativo, realocar o permissions.json, aposentar a linha do container
- Status: Aceito
- Data: 2026-08-23
- Contexto: fecha o item de backlog "converter o
execution_modede um projeto EXISTENTE" (Onda 2 do lote runner/execution_mode,docs/explanation/backlog.md), corrige o item 4 do ADR 0104 - Revisa (sem editar): ADR 0104 (a afirmação do item 4), ADR 0072, ADR 0081
Contexto
O ADR 0104 (Onda 1 do mesmo lote) introduziu o execution_mode de três
valores (container/mounted/runner) na CRIAÇÃO do projeto e, no item 4,
afirmou como consequência — não algo que ele desenhou — que converter entre
os três modos num projeto existente "deixa de exigir recriar o projeto". Essa
frase foi escrita antes de alguém rastrear o que de fato aponta para o escopo
antigo. A própria implementação da Onda 1 descobriu que ela estava errada e
registrou a correção em backlog.md em vez de editar o ADR aceito:
UpdateProjectDto continua excluindo de propósito
executionMode/workspacePath (apps/api/src/interfaces/http/iam/dto/update-project.dto.ts)
porque três coisas apontam para o par (modo, caminho) antigo e nenhuma delas
acompanha um UPDATE puro de coluna:
- o
permissions.json, escrito fisicamente emprojectScopeRoot(project)— um lugar diferente do sistema de arquivos para cada modo (fs-permissions-file-store.ts); project_containers(ADR 0081), cuja única máquina de estados válida supõeexecution_mode = 'container'—RegistrarTransicaoDeContainerUseCaserecusa (400) tocar a linha caso contrário;- um
Engine.Dev.DevAgentServerem execução, que captura oworkspace_rootUMA vez, no estado do GenServer, no momento da criação do worktree (Engine.Actions.Workspace.ensure_remoto/2→workspace_dir/1,2→Engine.Projects.Project.workspace_dir_name/1) — o gêmeo, no engine, deprojectScopeRoot. Virar a coluna do banco por baixo de um agente vivo não o move; só o PRÓXIMO worktree resolve contra o lugar novo.
Este ADR desenha a conversão que a Onda 1 adiou, e fecha a correção
registrada em backlog.md — a afirmação do item 4 do ADR 0104 volta a ser
VERDADE, pelo mecanismo abaixo, não editando o texto aceito.
Decisão
1. Uma rota dedicada, não um UpdateProjectDto afrouxado
PUT /projects/:projectId/execution-mode (papel maintainer — a mesma
gravidade que o CLAUDE.md já atribui a mudar max_parallel/orçamento de área:
"mudar o teto é decidir quanto o produto gasta sem pedir"), sustentada por
ConvertProjectExecutionModeUseCase. UpdateProjectDto continua excluindo os
dois campos, e o comentário dele agora aponta para cá em vez de só descrever a
lacuna. O PATCH genérico é uma escrita de coluna; a conversão é uma
ORQUESTRAÇÃO — validar o novo (modo, caminho), recusar com dev agent ativo,
realocar o permissions.json, aposentar a linha do ciclo de vida do container
ao sair de container, e só então escrever a coluna —, e misturar isso no DTO
genérico faria o caso simples (renomear um projeto) carregar a complexidade do
raro.
2. RN-447 — recusar (409) enquanto qualquer dev agent do projeto não estiver ocioso; nunca drenar/migrar
O risco de concorrência é real, não teórico: DevAgentServer não volta a
resolver o lugar do worktree, então converter por baixo de um agente vivo o
deixaria escrevendo num escopo que o permissions.json e a política de
caminho do terminal (ADR 0055) já abandonaram. A checagem lê
engine.dev_agent_states direto — entre schemas, no mesmo banco físico, o
mesmo precedente do onlineAgentCount da RN-409 — buscando
status <> 'idle' em TODAS as sessões do projeto (não no event log de uma
sessão, diferente de GetSessionPendingWorkUseCase, que é escopado por sessão
e exigiria consultar toda sessão que o projeto já teve). idle_tripped conta
como ativo de propósito, diferente da contagem de online da RN-409: um agente
com o disjuntor aberto ainda segura um workspace_root capturado e precisa de
um humano para desbloqueá-lo, mesmo não estando "online" pela definição da
RN-409 — as duas perguntas ("alguém está acompanhando este agente trabalhar"
vs. "alguém tem um ponteiro de escopo que ficaria velho") são diferentes, e a
exclusão de idle_tripped da RN-409 não se transfere para cá.
A decisão é RECUSAR e explicar, nunca drenar nem forçar a migração de um agente vivo — o mesmo padrão "recusar e ensinar" das mensagens de validação de disco da RN-088/RN-422. Construir um caminho de migração para um agente em voo significaria ou matar o processo dele no meio da tarefa (perdendo o que ainda não foi commitado) ou ensiná-lo a resolver o escopo de novo ao vivo, o que nenhuma outra parte do produto faz também.
3. RN-448 — o permissions.json é REALOCADO, o conteúdo nunca muda
PermissionsFileStore ganhou move(from, to): lê o arquivo no
projectScopeRoot ANTIGO, escreve o MESMO conteúdo no NOVO e então apaga, em
melhor esforço, o arquivo antigo. O conteúdo (padrões allow/deny/ask) não
carrega caminho nem execution_mode dentro dele — é puro histórico de política
("este comando exato foi sempre-aprovado"), então não há nada a reescrever, só
onde o arquivo físico mora. Verificado rastreando projectScopeRoot de ponta
a ponta (project-workspaces-root.ts): para mounted/runner ele devolve o
próprio workspacePath, tratado como um caminho dentro do sistema de arquivos
do próprio CONTAINER da API — para runner isso já é verdade hoje, mesmo sem
conversão, já que não há bind-mount; o arquivo mora fisicamente num caminho
que só coincide em STRING com a pasta real do usuário, desconectado dela. A
conversão não muda essa propriedade existente, só realoca o arquivo de forma
coerente com ela — nenhum tratamento especial para runner além de usar
move() como em qualquer outro par.
4. RN-449 — sair de container aposenta a linha de project_containers antes de a coluna virar
Se o execution_mode é container hoje e o destino não é,
RegistrarTransicaoDeContainerUseCase leva a linha do ciclo de vida do
container (quando existe uma) a removed — passando antes por stopped
quando ela está running, já que a máquina de estados de
container-lifecycle.ts não tem aresta direta running -> removed — ANTES de
a coluna execution_mode do projeto mudar. A ordem importa: o próprio
RegistrarTransicaoDeContainerUseCase recusa (400) qualquer transição num
projeto que não esteja hoje com execution_mode = 'container', então chamá-lo
depois da virada falharia sempre. Entrar em container VINDO de
mounted/runner faz o oposto de nada: nenhum provisionamento automático — o
portão de imagem do Arquiteto (RN-105) e o ciclo de vida normal do container
valem a partir daí, exatamente como para qualquer projeto container, nunca
um atalho que pula o portão porque o projeto "já teve código em algum lugar".
5. RN-450 — workspaceVerifiedAt volta a null em toda conversão real
Ele só significa algo para runner (RN-423): um carimbo provando que um
runner conectado confirmou o caminho no host real. Qualquer conversão —
inclusive uma que volte a runner com o que parece ser o mesmo caminho —
força uma NOVA confirmação, porque o carimbo antigo atesta uma checagem de
caminho que aconteceu sob um par (modo, caminho) DIFERENTE; levá-lo adiante
afirmaria uma confirmação que nunca aconteceu para o estado novo.
6. O mesmo (modo, caminho) de hoje é um no-op — por desenho, não por acaso
Se o par pedido resolve exatamente ao que o projeto já tem, o caso de uso
devolve o projeto intocado: nenhuma checagem de dev agent, nenhum move do
permissions.json, nenhum reset de workspaceVerifiedAt. Reenviar o mesmo
formulário não deveria custar uma varredura de agentes vivos, nem
desverificar em silêncio um projeto runner já confirmado.
Consequências
O que este ADR NÃO move: o código que já está no disco. A conversão nunca
copia arquivos entre a raiz de escopo antiga e a nova. Isto deliberadamente não
é uma lacuna que a implementação tenta disfarçar — é consequência de como o
engine já deriva um workspace (Engine.Actions.Workspace.ensure!/4,
apps/engine/lib/engine/actions/workspace.ex): um diretório de workspace que
ainda não está marcado como pronto recebe git init + fetch a partir do
repositório BARE do projeto (a fonte de verdade de fato) na próxima criação de
worktree, seja qual for o (modo, caminho) para onde a coluna aponta agora. O
trabalho commitado está seguro — ele mora no repositório bare e é feito
checkout de novo no lugar novo. Mudanças não commitadas no worktree antigo
abandonado não são migradas nem apagadas — ficam órfãs no lugar, ainda
fisicamente no disco, na raiz de escopo antiga (recuperáveis à mão, nunca
automaticamente), mas invisíveis ao produto a partir do momento da conversão.
A guarda da RN-447 reduz esse risco (nenhuma conversão enquanto um agente está
ativamente working), mas não o elimina: um dev agent idle entre tarefas
ainda pode estar sentado sobre um diff não commitado, e nada aqui inspeciona a
limpeza da árvore de trabalho antes de permitir a conversão.
O que este ADR não constrói: um jeito de converter o MESMO projeto para um
modo ao qual ele já é plenamente equivalente sem disparar uma marca de
"mudou" — não é necessário, ver a Decisão 6. Ele não toca UpdateProjectDto,
não acrescenta migration (ProjectRepository.update já aceitava essas três
colunas desde o ConfirmProjectWorkspaceUseCase do ADR 0104/RN-423) e não
muda a política de escopo de caminho do terminal (ADR 0055) nem o portão de
imagem (RN-105) além do que a Decisão 4 já diz.