ADR 0145 — Docker becomes a real prerequisite of the runner mode
Context
The runner mode (ADR 0104) exists for code that lives in a user folder WITHOUT
a bind-mount to the server, confirmed by a CLI (brabo-runner) running on their
machine. Since ADR 0137 (RN-497), that same CLI also brings up a Docker container
ON THE USER'S MACHINE, and Engine.Actions.TerminalExecutor routes every
approved terminal command to the runner whenever the workspace is VERIFIED and
the runner is CONNECTED — without checking whether there is any running
container. The host-vs-container choice was entirely INTERNAL to the runner
(EstadoDoRunner.containerAtivo): with a container up, it ran via
docker exec; without one, it silently fell back to the bare HOST, executing
the command outside any isolation.
That left runner inconsistent with container/mounted, which since ADR 0143
(RN-502) already REFUSE to run a terminal without a registered running
container — they never degrade to local System.cmd. For runner, the "local"
was the user's own host: the same silent fallback ADR 0143 closed for the other
two modes remained open here, outside the server.
A second defect, declared since RN-478 and never fixed: the materialization of
the dev agent's working tree (Engine.Actions.Workspace.ensure!/4) tried a LOCAL
File.mkdir_p!/System.cmd("git", ...), inside the engine process, against a
path that only physically exists on the user's machine — the engine process has
no bind-mount there at all. The previous fix only improved the MESSAGE of an
inevitable failure; the dev agent still had nowhere to work.
A third point, found during the investigation: container_start (the action
that actually brings up the container, ADR 0130/0133) also served runner, but
its payload (imagem/network/resources/rationale — Infra ELECTS a
candidate from the Arquiteto's routing) never made sense for that path: the
broker never reaches a runner project's folder, so there is no routing to elect
against, and the image that comes up is simply the one already decided.
Decision
Docker becomes a real prerequisite of the runner mode, with no fallback to
the host. The three preconditions runner needs for any command to reach the
user's machine — workspace CONFIRMED, runner CONNECTED and container REGISTERED
running (ProjectContainerLifecycle.running?/1, the SAME predicate as
container/mounted) — live in a single function,
Engine.Runners.RunnerReadiness, with two consumers: TerminalExecutor (which
already checked the first two) and the new module below.
Worktree materialization now happens INSIDE the project's real container, on
the user's machine — through the SAME channel that already executes the
terminal. Engine.Actions.Workspace.ensure!/4 forks by execution_mode:
local for container/mounted (unchanged), via
Engine.Actions.Workspace.RunnerGit (new) for runner. RunnerGit does
git init/remote add/fetch/checkout, and the equivalents of
File.dir?/ls/rm_rf that Engine.Dev.WorktreeManager needs, as normal
shell commands, delivered by Engine.Runners.RunnerRouter.exec/5 — never a new
mechanism, always after RunnerReadiness.verificar/1 confirms the three
preconditions. WorktreeManager (create/3, remove/2, list/1,
cleanup_orphans/2) forks by the same criterion, and the periodic job
Engine.Dev.WorktreeCleanup now SKIPS (does not fail) a runner project
without a runner ready RIGHT NOW, continuing normally for the others.
The exec/exec_result protocol gains an optional env field. The
authenticated git fetch needs the provider credential (ADR 0056), which can
only travel in the child process's environment — never argv, never a file. Since
that child process is now born on the user's machine, env (a
Record<string,string>) enters the payload the engine dispatches; the runner
MERGES that env on top of process.env before the spawn (never replaces it
— it would lose PATH) and never passes it to the docker exec path (no env
support in packages/docker-port, on purpose — see Consequences) nor prints it
in any log.
container_start migrates entirely to being exclusive to
container/mounted (both through the broker, RN-503).
container_start_via_runner is born, a new action type, exclusive to
runner. Same caliber as container_start (maintainer, require_approval
by default, configurable in permissions.json) — but the schema only has an
optional rationale: it elects no candidate at all, it brings up the image
already DECIDED (ObterSpecDeContainerUseCase). Engine.Infra.Tools. ProposeContainerStartViaRunner, the Infra Lead's new tool, is intercepted by
the InfraLeadServer BEFORE calling propose_action — it LOCALLY queries
(Project.get/1 + Engine.Runners.Registry.connected?/1, without HTTP, in the
same BEAM process) the execution_mode and the presence of a connected runner,
refusing with a named reason instead of proposing blindly.
Consequences
- A
runnerproject without arunningcontainer loses the terminal and the dev agent's worktree until someone proposes and approvescontainer_start_via_runner. Accepted cost, MEASURED against the real precedent: the sibling change forcontainer/mounted(ADR 0143, RN-502) already had the same shape — "now requires where it did not before" — and shipped asfeature/, MINOR, without abreaking/branch. No existingrunnerproject had guaranteed isolation before this delivery (the silent fallback to the host is exactly what is being closed), so there is no prior correct behavior being taken away — only an unsafe behavior ceasing to exist. envonly reaches thegit fetchwhen the runner chooses the HOST path. If the runner already has an active container (EstadoDoRunner.containerAtivo),tratarExecroutes EVERYexec— including those ofRunnerGit— viadocker exec, which has noenvfield (a measured decision of ADR 0130: keep the Docker port with a closed type, with no free-e). Agit fetchdispatched in that state silently loses the credential for a private repository. Not solved in this delivery — extendingpackages/docker-portto acceptenvwould touch BOTH of its consumers (runner and broker) and was out of scope; declared for when the volume ofrunnerprojects with a private repository justifies it.RunnerReadiness.verificar/1runs again on eachRunnerGitcall (one more query per git/worktree operation) — acceptable because no function of this module is on the hot path (the same rule that already held forEngine.Actions.Workspace.ensure!/4before this delivery).- The gap RN-494 declared for
propose_container_start(neither it norGetInfraContextUseCaserestrict byexecutionMode— the Infra Lead can propose blindly) remains exactly as it was for that tool. The NEW tool (container_start_via_runner) is born without that gap because it is born knowing how to refuse — but that is not retroactive:propose_container_startstill cannot distinguish the mode, a separate decision, outside the API/domain scope.