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 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. AgentRunTerminationReasoncompleted, 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.