ADR-102: Queued agent runs over the TYPO3 message bus
- Status
-
Accepted
- Date
-
2026-07-21
- Authors
-
Netresearch DTT GmbH
Context
AgentRunStatus::QUEUED has been reserved vocabulary since ADR-081, and
ADR-101 deliberately deferred asynchronous execution: a
start()/run() 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: MessageBusInterface is autowireable, handlers register via
#[AsMessageHandler], 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/typo3 messenger:consume. No composer change is needed.
Decision
AgentRuntimeInterface (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_request column on a
QUEUED tx_nrllm_agentrun 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
SuspendedRunState precedent). Two round-trip details are load-bearing:
plannedCost and the idempotency key are deliberately excluded from
ToolOptions::toArray() (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 RunAugmentation 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 AgentRunQueuedMessage
carrying nothing but the uuid. AgentRunQueuedHandler
(#[AsMessageHandler]) calls runQueued(), which atomically claims the
row — a guarded QUEUED → RUNNING UPDATE in the claimForResume() idiom,
stamping started_at plus a worker lease (claimed_by,
lease_expires) — 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
RunEnqueueFailedException; a dispatch failure settles the just-stored row FAILED first — no orphaned QUEUED run that no message will ever wake. - 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.
run()andrunQueued()share one execution path, so the iteration ceiling, the trace wiring and the outcome taxonomy cannot drift.- The handler never throws for run outcomes, so under
messenger:consumea 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.
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_request mediumtext (cleared by the guarded terminal settle,
like suspended_state — and stripped from status() for the same ADR-064
privacy reason), claimed_by varchar(64) and lease_expires int 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_FOR_INPUT 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. AgentRunRepositoryInterfacegainedenqueueRun()andclaimQueued()(breaking for out-of-tree implementors — the interface is in-repo plus test doubles by design).AgentRuntimegained optionalMessageBusInterface/SkillRepository/PromptSnippetRepositorydependencies (autowired in production); with no bus wired,enqueue()fails closed.- A run FAILED by a dispatch or rehydration failure records
PROVIDER_FAILEDas its termination reason — acceptable coarseness until the retry epic introduces per-failure-class classification at the run level (ADR-095 groundwork exists).