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_ 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
ToolwithLoop Result 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.
settlestored 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.Failed () - Settling was unguarded.
finishupdated byRun () uidalone. 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_, 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. Agent —
completed, max_, budget_, policy_,
approval_, provider_, cancelled — is carried on
Tool from the loop's exit path and stored in a new
termination_ column. The status stays the coarse lifecycle state; the
reason explains it. is 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.
settle records FAILED with policy_ for an outright
denial and approval_ 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. finish 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 claim
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: 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
Toolgains a constructor parameter with a default, so existing positional constructions keep working;Loop Result truncatedis retained rather than derived, because "the answer is incomplete" and "this is why" are different questions and consumers already read the former.Agentgains a parameter and returnsRun Repository Interface:: finish Run () boolinstead ofvoid— a breaking change to a DI-private interface, called only by the persister.AgentgainsRun terminationandReason termination. Unknown stored values return null rather than being coerced, matching howReason Enum () statusalready guards forward compatibility.Enum () - 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_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.FOR_ INPUT - Guardrail approval remains terminal: the run ends with
approval_rather than suspending for a decision, becausedenied Guardrailcarries 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.Approval Required Exception