ADR-102: Queued agent runs over the TYPO3 message bus
- Status
-
Accepted
- Date
-
2026-07-21
- Authors
-
Netresearch DTT GmbH
Context
Agent has been reserved vocabulary since ADR-081, and
ADR-101 deliberately deferred asynchronous execution: a
start split is only real once the request payload is persisted so a
different process can execute it. Every consumer that wants batch runs,
review queues or scheduled agent work needs exactly that — enqueue now, execute
in a worker, poll status.
TYPO3 core (v13.4 and v14.3 identically) ships Symfony Messenger as the
message bus: Message is autowireable, handlers register via
#, the default routing sends every message to the
synchronous transport, and an installation opts into asynchronous execution by
routing messages to the core-provided doctrine transport and running
bin/. No composer change is needed.
Decision
Agent (consumer contract: methods may be added in minor
releases, ADR-101) gains the queued half of its lifecycle:
public function enqueue(AgentRunRequest $request): string; // returns run uuid
public function runQueued(string $runUuid, ?Closure $onStep = null): ?AgentRunResult;
The run row is the state; the message is only a wake-up call.
enqueue serialises the request into a new queued_ column on a
QUEUED tx_ row (entities travel as uids and are re-loaded at
execution time — the same identity-over-snapshot choice approve makes;
messages and options use their established array forms, the ADR-084
Suspended precedent). Two round-trip details are load-bearing:
planned and the idempotency key are deliberately excluded from
Tool (sound for the ADR-084 resume, whose pre-flight
already ran) but a queued run's budget pre-flight has not happened yet, so
they travel out-of-band in the payload and are re-injected at rehydration —
run and enqueue of the same request hit the identical budget gate
and dedup. And a null Run stays null: fabricating an empty one
would flip the loop into its prompt-baking assembly branch and silently change
the prompt composition versus the identical direct run. enqueue then
dispatches an Agent
carrying nothing but the uuid. Agent
(#) calls run, which atomically claims the
row — a guarded QUEUED → RUNNING UPDATE in the claim idiom,
stamping started_ plus a worker lease (claimed_,
lease_) — rehydrates the request and drives the identical
fail-closed ladder as run.
Consequences of that split:
- Duplicate or stale messages are harmless — the claim decides; the loser
sees
nulland the handler treats it as a non-event (no redelivery). - Cancel-while-queued works with zero new code: the guarded terminal transition already covers QUEUED, and a cancelled run is unclaimable.
- Fail-closed enqueue: a row that cannot be stored throws
Run; a dispatch failure settles the just-stored row FAILED first — no orphaned QUEUED run that no message will ever wake.Enqueue Failed Exception - Fail-closed execution: the claim comes first; a rehydration failure (corrupt payload, configuration deleted while queued) settles the claimed run FAILED instead of stranding it. A skill or snippet deleted while queued is simply no longer forced — live resolution, like the interactive path.
runand() runshare one execution path, so the iteration ceiling, the trace wiring and the outcome taxonomy cannot drift.Queued () - The handler never throws for run outcomes, so under
messenger:a failed run is a handled message (the row carries the outcome) — messenger retry/dead-letter machinery, which TYPO3 wires none of, is not relied upon.consume
Operations
Default (no configuration): the SyncTransport executes the run in-process
during enqueue — semantically identical to run, just addressed by
uuid. For genuinely asynchronous execution the installation routes the message
to the doctrine transport and runs a consumer:
// settings.php / additional.php
$GLOBALS['TYPO3_CONF_VARS']['SYS']['messenger']['routing']
[\Netresearch\NrLlm\Service\Agent\Queue\AgentRunQueuedMessage::class] = 'doctrine';
bin/typo3 messenger:consume doctrine
Schema: queued_ (cleared by the guarded terminal settle,
like suspended_ — and stripped from status for the same ADR-064
privacy reason), claimed_ and lease_ with
fail-safe ''/0 defaults. The lease (15 min) is written at claim time
and is diagnostic for now — the stale-run reaper, heartbeat, per-failure-class
retry and WAITING_ are the remaining slices of the queue epic,
each its own step on top of this substrate.
Consequences
- Batch runs, review queues, scheduled agent work and editor actions can
enqueue through the public runtime and poll via
status/() events— no playground involvement, no bespoke lifecycle.() AgentgainedRun Repository Interface enqueueandRun () claim(breaking for out-of-tree implementors — the interface is in-repo plus test doubles by design).Queued () Agentgained optionalRuntime Message/Bus Interface Skill/Repository Promptdependencies (autowired in production); with no bus wired,Snippet Repository enqueuefails closed.() - A run FAILED by a dispatch or rehydration failure records
PROVIDER_as its termination reason — acceptable coarseness until the retry epic introduces per-failure-class classification at the run level (ADR-095 groundwork exists).FAILED