ADR 102 · Accepted
ADR-102: Queued agent runs over the TYPO3 message bus
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 null and 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() and runQueued() 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:consume 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.
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.
AgentRunRepositoryInterface gained enqueueRun() and claimQueued() (breaking for out-of-tree implementors — the interface is in-repo plus test doubles by design).
AgentRuntime gained optional MessageBusInterface / SkillRepository / PromptSnippetRepository dependencies (autowired in production); with no bus wired, enqueue() fails closed.
A run FAILED by a dispatch or rehydration failure records PROVIDER_FAILED as its termination reason — acceptable coarseness until the retry epic introduces per-failure-class classification at the run level (ADR-095 groundwork exists).