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;
Copied!

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';
Copied!
bin/typo3 messenger:consume doctrine
Copied!

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).