---
title: "ADR-102: Queued agent runs over the TYPO3 message bus"
manual: "TYPO3 LLM Extension"
version: "0.35"
permalink: "https://docs.typo3.org/permalink/netresearch/nr-llm:adr-102@0.35"
source: "Adr/Adr102QueuedAgentRuns.rst"
modified: "2026-09-16T22:09:16+00:00"
---

# 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](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-101@0.35) 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:

```php
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:

```php
// settings.php / additional.php
$GLOBALS['TYPO3_CONF_VARS']['SYS']['messenger']['routing']
    [\Netresearch\NrLlm\Service\Agent\Queue\AgentRunQueuedMessage::class] = 'doctrine';
```

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