ADR 092 · Accepted
ADR-092: A run records why it ended, and cannot be settled twice
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 ToolLoopResult with truncated = 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 by uid alone. The streamed path settles in a finally block 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
ToolLoopResult gains a constructor parameter with a default, so existing positional constructions keep working; truncated is 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 returns bool instead of void — a breaking change to a DI-private interface, called only by the persister.
AgentRun gains terminationReason and terminationReasonEnum(). Unknown stored values return null rather than being coerced, matching how statusEnum() 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_INPUT still 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_denied rather than suspending for a decision, because GuardrailApprovalRequiredException carries 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.