ADR-081: Agent run persistence and a durable event stream
- Status
-
Accepted
- Date
-
2026-07-18
- Authors
-
Netresearch DTT GmbH
Context
A run of the tool-calling agent loop (Tool) existed only
for the lifetime of one HTTP request. It returned a Tool value object
and was then gone. The only inspectable trace, Run, is an in-memory
collector the admin playground builds to stream steps to the browser; nothing was
written to storage.
That is the missing foundation under every larger agent capability: a run cannot
be resumed after a worker restart, queued for later execution, paused for a human
approval, audited, or replayed by a consumer UI, because there is no persisted run
to attach any of that to. Cross-call usage already carried a correlation_
(tx_), but nothing promoted it into a first-class run.
Decision
Persist each run and its steps in two UI-less log tables,
tx_ (one summary row per run) and tx_ (one
row per recorded step, ordered by sequence). Both follow the telemetry
precedent (ADR-058): raw Doctrine access through
Agent, no Extbase model and no TCA, because a run log is written
and read programmatically, never edited in FormEngine.
Drive persistence from the existing `RunTrace.onRecord` hook. A fail-soft
Agent opens a run (begin → RUNNING), records each Run as
an event as it is emitted (record), and settles the run to a terminal
state (settle / settle). The tool loop is not touched:
the persister is wired through the same per-step callback the playground already
uses to stream steps, so the loop's control flow is unchanged and unaware of it.
Type the vocabulary without inventing what the loop cannot emit.
Agent carries the full lifecycle the later epics need (queued,
running, waiting_for_approval, waiting_for_input, completed,
failed, cancelled), but this change only ever writes RUNNING →
COMPLETED/FAILED. Agent is limited to the four kinds Run actually
produces (request, llm, tool, assembled); richer kinds are added
by the epics that emit them.
Fail-soft is a hard requirement. Every persister method logs and swallows a
storage error; begin returns null on failure, which the caller treats exactly
as a null Run callback — "record nothing". A database hiccup can therefore
never break an otherwise-successful run. The streamed path settles the run in a
finally block (mirroring Streaming), so a client disconnect
mid-stream cannot leave a run stuck RUNNING.
Consequences
- Playground runs (batch and streamed) now persist. The admin playground is the first consumer; the tables are the substrate the human-in-the-loop, batch/queue and feedback epics build on.
- A run that exhausts its iteration cap or is denied by the budget guard both
surface as COMPLETED with
truncated = true, becauseToolcatches the budget denial internally and returns a normal result. Recording a budget denial as a distinct FAILED run is deferred to the human-in-the-loop epic, which reshapes the loop's exit paths.Loop Service - Event payloads store the full
Runsnapshot, which includes the messages sent (the prompt).Step Agentprovides the retention hook; a scheduled purge command is a follow-up. Runs are admin-authored for now (the playground is admin-only), so the exposure is limited.Run Repository:: purge Older Than () - The persister depends on
Agent, so it is unit-tested against a recording double; the raw SQL and schema are covered by a functional round-trip.Run Repository Interface