ADR 0019 — Unblocking the real DevAgent: writing, terminal, ADR by module, and per-project budget
- Status: accepted
- Date: 2026-07-25
- Phase: 4a (closing the deviations of the real DevAgent)
Context
The real DevAgent (ADR 0012) was complete on paper — ToolLoop in the worktree,
ReportDone requiring a green suite, blocking with diagnostics, per-task
budget — but had never run the acceptance criterion with a real LLM. When
attempted, the audit found the reason: it couldn't implement anything.
Decisions
1. The DevAgent was blocked at TWO points (the central finding)
write_filefrom adev-*was not in theWriteFilePolicywhitelist (the default only hadecho), so it turned into aproposed_action— andwrite_filehas no executor in the api, meaning even approved it wouldn't write.terminalhad no rule at all:decide()fell intorequire_approvaland the action was born pending.
Since ReportDone only releases a PR after a terminal with exit 0 in the
history, a green suite was unreachable: every task ended up blocked by the
iteration limit. The enforcement of the completion discipline was correct;
what was missing was the agent's ability to act.
Fixes:
WriteFilePolicygains agent prefixes (:write_file_agent_prefixes, default["dev-"]): the dev writes to any path in its own root. This is safe because its root IS the worktree andWorkspaceFiles.write_file/3already blocks traversal — the sandbox is the worktree, not the path prefix list.DEV_TERMINAL_ALLOW_PATTERNS(api domain) is seeded intopermissions.jsonon activation. NARROW test/build patterns (Terminal(pnpm test),Terminal(npm test),Terminal(mix test), …), with override in the DTO. Deliberately notagent_autonomy auto_approveforterminal: that would release any command. Being a file rule,denystill wins, theBUILTIN_DENY_PATTERNSremain active, and a compound command requires every segment to match.
2. ADRs filtered by module
open_adr_pr gains modules?: string[] (filled in by the Architect's tool).
GetDevTaskContextUseCase filters by the dev's module; an ADR without
modules is cross-cutting and always included — which covers the entire
pre-existing collection before the field, with no data migration. With no
module informed there is no filter, preserving the QA/SecOps gates, which
reuse the same context.
3. Prompt discard priority was inverted
estado_tarefa was [story, task | adrs] and PromptAssembler.fit_units/3
discards from the head: under context pressure, the story (FR/NFR/DoD)
was sacrificed and the ADRs survived — the opposite of what serves whoever is
going to implement. It's now adrs ++ [story, task].
4. Per-task budget persisted on the project
It was just an activation parameter, living in engine.dev_agent_states:
reactivating without passing it again would silently fall back to the
default. New column projects.task_budget_micros; resolution
parameter → project → default, with persistence when it comes from the
parameter.
5. task_blocked artifact
The block only emitted an event + a flag in the database. It now also emits
artifact.task_blocked, validated by ArtifactSchemas — server-emitted,
never via tool call (the model doesn't get to choose to declare that it gave
up).
6. Diagnosability: an LLM error must not turn into an empty diagnostic
ToolLoop returned {:ok, ctx} both when the model stopped without
signaling and when the call to the provider FAILED — and DevAgentServer
would block the task with an empty "" diagnostic. The ctx now carries
:last_error and the diagnostic distinguishes the two cases. This isn't
theoretical: the first real demo died on an invisible
Req.TransportError{reason: :timeout}.
7. LLM turn timeout
llm_turn used Req's default. With a local model, the FIRST turn still loads
several GB of weights before the first token and times out. New
llm_turn_timeout_ms (default 300s, env LLM_TURN_TIMEOUT_MS).
8. Node in the engine image
The managed project's suite runs inside the engine container (that's
where terminal executes, in the worktree). Without the toolchain, npm test never returns exit 0. Known limitation: this doesn't scale to
arbitrary stacks — the real solution is a per-project sandbox, out of scope
for Phase 4a.
Consequences
- New tests:
write_file_policy(dev writes in its root; traversal blocked), the dev'scontext_builder(FR/NFR/DoD + module ADRs + AGENTS.md actually in the prompt; ADRs discarded before the story), red suite up to the limit with the test output in the diagnostic,task_blockedartifact, ADR filtering by module,activate-execution(budget order, terminal patterns, nevergit_merge), andlib/execution.tson the web. - UI: task title in the panel (previously only the
task-<8hex>branch), backlog invalidation via the channel (blocked live) and an unblock button.
Acceptance criterion result (honest)
The entire chain was exercised end to end with a local LLM: atomic claim,
isolated worktree, assembled context, LLM turn, and the block outcome with
diagnostics and artifact. What did NOT close were the 2 green PRs — and
the cause is the model, not the platform: qwen2.5-coder:7b doesn't
implement tool calling in the Ollama template. Verified directly against
/api/chat, with stream: false and a correct tools payload, the response
comes back with tool_calls: null and the call written as JSON in
content. No adjustment to OllamaProvider fixes this.
Closing the criterion requires a model with a real tools template
(llama3.1:8b or larger) or a paid provider. It's the user's decision — the
platform is ready for either.