ADR-092: A run records why it ended, and cannot be settled twice
- Status
-
Accepted
- Date
-
2026-07-20
- Authors
-
Netresearch DTT GmbH
Context
tx_nrllm_agentrun recorded what state a run was in and nothing about how
it got there. Three consequences, all of them operational:
- A budget stop and an iteration cap were indistinguishable. Both return a
normal
ToolLoopResultwithtruncated = true— the loop deliberately swallows the budget denial so the partial trace survives — and both are settled COMPLETED. The stored row could not tell an operator whether a run ended because the prompt needed more rounds or because the money ran out. Only a log line, thrown away with the next rotation, carried the difference. - A guardrail stop was recorded as a crash.
settleFailed()stored the exception FQCN, so a policy decision looked exactly like a provider outage in the run table. ADR-086 claimed the row reflected the guardrail verdict; it did not. - Settling was unguarded.
finishRun()updated byuidalone. The streamed path settles in afinallyblock precisely because a client disconnect can abandon a run, so a late settle landing on an already-completed run would overwrite its totals and error class.
Three enum cases — QUEUED, WAITING_FOR_INPUT, CANCELLED — also had
no writer at all. CANCELLED in particular left operators with no way to
retire a run that a dead PHP process left RUNNING, or an approval nobody would
ever give.
Decision
Status and reason are separate fields. AgentRunTerminationReason —
completed, max_iterations, budget_exhausted, policy_denied,
approval_denied, provider_failed, cancelled — is carried on
ToolLoopResult from the loop's exit path and stored in a new
termination_reason column. The status stays the coarse lifecycle state; the
reason explains it. isRetryable() on the enum answers the question a retry
policy actually asks: only a provider failure may be worth another attempt —
an exhausted budget or a policy decision will not fix itself.
Guardrail stops are policy outcomes, not failures.
settlePolicyStopped() records FAILED with policy_denied for an outright
denial and approval_denied when a guardrail required an approval that was
never obtained. The HTTP contract is unchanged (200 with success: false,
ADR-086); only the persisted reason gains meaning — and ADR-086's claim about
the row now holds.
Terminal is terminal. finishRun() updates only rows whose status is
non-terminal and returns whether it transitioned. A duplicate or late settle
keeps the first outcome and is logged at notice level rather than silently
merged. This is the same conditional-UPDATE technique claimForResume()
already used for the double-approval race (ADR-084).
Suspension is fail-closed for the caller. suspend() still swallows the
store error — the persister is fail-soft by design — but now reports it. The
playground fails the run instead of answering "awaiting approval", because an
approval-gated tool is by definition side-effecting: promising a resume that
cannot happen is worse than an honest error. Read-only recording stays
fail-soft; a database hiccup must not break an otherwise successful run.
Cancellation is implemented, not merely enumerated.
nrllm:agent:cancel <uuid> moves a non-terminal run to CANCELLED through the
same guarded transition, dropping its resumable state. CANCELLED is distinct
from FAILED: nothing went wrong, somebody stopped it.
Consequences
ToolLoopResultgains a constructor parameter with a default, so existing positional constructions keep working;truncatedis retained rather than derived, because "the answer is incomplete" and "this is why" are different questions and consumers already read the former.AgentRunRepositoryInterface::finishRun()gains a parameter and returnsboolinstead ofvoid— a breaking change to a DI-private interface, called only by the persister.AgentRungainsterminationReasonandterminationReasonEnum(). Unknown stored values return null rather than being coerced, matching howstatusEnum()already guards forward compatibility.- Runs written before this change carry an empty reason. That is honest — the information was never recorded — and reads as "unknown", not as "completed normally".
WAITING_FOR_INPUTstill has no writer. It stays in the enum as the reserved state for the queue work (roadmap P1), and this ADR does not pretend otherwise.- Guardrail approval remains terminal: the run ends with
approval_deniedrather than suspending for a decision, becauseGuardrailApprovalRequiredExceptioncarries no resumable state (ADR-086). Making it resumable means teaching the guardrail path to hand back the flagged content and the transcript, the way the tool-approval path already does — a separate change with its own ADR, not a side effect of this one.