---
title: "ADR-283: An A2A task runs in the worker, and the request only waits"
manual: "LLM Foundation"
version: "main"
permalink: "https://docs.typo3.org/permalink/netresearch/nr-llm:adr-283@main"
source: "Adr/Adr283AnA2aTaskRunsInTheWorker.rst"
rendered: "2026-11-12T15:41:35+00:00"
---

# ADR-283: An A2A task runs in the worker, and the request only waits {#adr-283}

-   *Status:* Accepted
-   *Date:* 2026-11-10
-   *Amends:*

    [ADR-219](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-219@main) (a sweep tick records a heartbeat too);
    [ADR-234](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-234@main) (a sweep-only installation has a heartbeat)

-   *Authors:* Netresearch DTT GmbH

## Context {#context}

In A2A 1.0, `SendMessage` blocks by default: with
`SendMessageConfiguration.return_immediately` unset or false, the server must
wait until the task reaches a terminal or an interrupted state before it
answers ([ADR-278](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-278@main)). An agent run takes seconds to minutes.

A PHP-FPM request is cut off by any proxy in front of it whose read timeout
passes without a byte ([ADR-276](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-276@main) measured that), and it holds a
PHP-FPM worker for as long as it lasts. Executing the run inside the request,
as the playground and the inbox do, would make the A2A endpoint as slow as its
slowest run and let remote clients occupy every PHP-FPM worker.

`AgentRuntimeInterface::defer()` stores a QUEUED run and dispatches a
message only when `QueueRouting::leavesTheProcess()` is true, that is when
the run's sender is not named `default`; otherwise the run waits for a sweep
(`nrllm:agent:work`) or a consumer ([ADR-234](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-234@main)).
`enqueue()` dispatches always and, on the synchronous `default` transport,
executes in the calling process. A worker heartbeat exists only for
`messenger:consume` (`WorkerHeartbeatListener`, read through
`WorkerOperationsReaderInterface::snapshot()`); the sweep has none, which is
why `nrllm:agent:status` needs `--minimum-workers=0` for it.

## Decision {#decision}

**A task is bound to a publication profile.** A profile publishes one skill
and binds a registered domain handler, the LLM configuration, the allowed tools
and resources, the execution identity ([ADR-280](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-280@main)), an optional
pinned workspace, the input and output schema, quotas, the retention and
whether the full text must be checked before release. One task has one run.

**Acceptance is one transaction.** The dedup record of the client's
`messageId`, the task, the run and the binding between them commit together,
and the response is sent only after the commit. A failed commit leaves none of
them.

**The run is queued with** `defer()`. The request creates the task and the
run, as the client's technical backend user, and never executes the agent loop.
A worker runs it.

**A task is accepted only when a worker can be shown to run.** Before creating
anything, the server requires a live `messenger:consume` heartbeat or a sweep
tick within the last 120 seconds. The sweep gets a heartbeat for this: each
tick of `nrllm:agent:work` records itself in the worker operations table,
which `nrllm:agent:status` then reads as well. Without either, the request is
refused with JSON-RPC `-32000`, HTTP 503, `Retry-After`, an `ErrorInfo`
with reason `WORKER_UNAVAILABLE` and a `google.rpc.RetryInfo` detail.
Nothing is stored.

**A blocking request waits, and never answers early.** It reads the task view
([ADR-282](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-282@main)) until a terminal or interrupted state. It does not
answer with a WORKING task in place of that wait: the proto says "must wait",
and a client that relies on the default cannot tell an early WORKING from a
result. The request has its own deadline, `a2a.server.blockingWaitSeconds`
(default 20, at most 100), which the documentation requires to be lower than
every proxy read timeout in front of PHP-FPM. When it passes, the client gets a
system error (HTTP 504, JSON-RPC `-32603`, `ErrorInfo` reason
`BLOCKING_DEADLINE_EXCEEDED` with the task id). The task is untouched and
retrievable by `GetTask`, and through `ListTasks` even by a client that
lost the body. A proxy that cuts the request earlier has the same effect on the
task.

**Waiting requests and long streams have separate capacities.** Waiting
blocking requests may not exceed `a2a.server.maxWaitingRequests` (default 4);
streams have limits per task, per client and per installation of their own. A
client's queued and running tasks may not exceed the limit on its record. A
request over a limit is refused like a missing worker, with a reason naming the
limit.

**A stream reads the journal, from one revision on.** `SendStreamingMessage`
and `SubscribeToTask` (stage 5a) take the task snapshot and its journal
cursor at the same revision, send the snapshot first and then only newer
events. The snapshot replaces whatever partial artifact the client composed.
A2A promises no replay, so recovery is a new snapshot or `GetTask`; a
`Last-Event-ID` header creates no further contract. A stream closes at a
terminal state or INPUT_REQUIRED, and stays open after AUTH_REQUIRED, whose
credential arrives out of band (section 7.6.1 of the specification). It also
closes after `a2a.server.streamMaxSeconds` (default 300), sends a comment
line at least every 15 seconds, caps its frame size and buffer, and disables
proxy buffering with `X-Accel-Buffering: no`. Closing a stream never cancels
the task.

**Content is held back until a full-text check has passed.** This is a
product decision. When a profile requires it, and by default whenever its
configuration has an output guardrail, no content fragment leaves the
installation before the whole text has passed the check: the stream carries
status progress only and the artifact follows in one event. A guardrail that
judges whole texts cannot be applied to fragments already sent, and a fragment
cannot be recalled. A failed check ends the task FAILED without content.

**Push notifications are delivered by the worker only**
([ADR-285](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-285@main)). No request makes a webhook call.

## Consequences {#consequences}

-   ●● A remote client cannot make a request execute a run, so it cannot
    occupy PHP-FPM workers beyond the capacity limits.
-   ● A refusal names its reason and when to try again; nothing is half-created.
-   ● The sweep's new heartbeat also lets `nrllm:agent:status` see a
    sweep-only installation.
-   ◑ A run longer than the blocking deadline gives a blocking client an error,
    not a result; it has to retrieve the task.
-   ◑ A profile with an output guardrail streams progress, not text.
-   ✕ An installation without a running worker cannot offer A2A at all.
-   ✕ A blocking request still holds one PHP-FPM worker for up to the deadline.

Net Score: +3

## Alternatives considered {#alternatives-considered}

**Execute the run in the request, as the inbox does.** Rejected: the run
length would become the request length, behind a proxy timeout, and remote
clients could exhaust PHP-FPM.

**Use** `enqueue()`. Rejected: on the synchronous `default` transport it
executes in the calling process, which is the request.

**Answer with the WORKING task when the wait runs long.** Rejected: it looks
like a valid blocking answer, so a client that relies on the default would take
an unfinished task for a result.

**Wait without a deadline of its own.** Rejected: the proxy then decides, and
the client receives a 504 without the task id.

**Stream text fragments and check afterwards.** Rejected: a fragment cannot be
recalled once a later check fails.
