ADR-234: Background work is a stored run and a sweep
- Status
-
Accepted (a background answer can also be written into a field, see ADR-237)
- Date
-
2026-11-01
- Amends
-
ADR-102 (a run may be queued without a wake-up message), ADR-153 (the detail page of a background run shows its answer)
- Amended
-
2026-11-01 by ADR-237 (a background answer that is written into a field)
- Authors
-
Netresearch DTT GmbH
Context
The worker side of a queue exists: the handler (ADR-102), the
reaper (ADR-104) and the status command
(ADR-219). Nothing produces work for it.
Agent has no caller in this extension, and
AI Tasks run only inside a backend request.
ADR-162 names the reason enqueue was not used: the
transport is the operator's choice and defaults to synchronous, so on a default
installation it executes the run inside the caller. Three coming producers
cannot live with that. A batch over a folder would hit the timeout that capped
ADR-162 at 20 records, a fill on save would block the save, and a scheduled task
on an installation with a scheduler and no Messenger consumer has nothing to
wake.
Decision
One queue, the run table, and a second way to work it. There is no table of its own. ADR-162 rejected that for batches, and the reason holds for tasks: a second lifecycle would need its own claim, lease, retry, cancel and retention.
Agent stores the same QUEUED row as
enqueue and never executes in the caller. It dispatches the wake-up
message only where the routing sends Agent to a transport
other than default, because on default the dispatch IS the execution. A
dispatch that fails is logged and the row stays queued, unlike enqueue,
which fails the run: here the sweep still finds the row.
nrllm: is the sweep. It reads QUEUED rows oldest first and calls
run, which claims atomically, so it can run beside a consumer and
beside itself. One tick is bounded by -- and --. A failed
run is that run's outcome and does not fail the command; a queue that cannot be
read does.
The budget is asked before the claim. A refusal after it costs the run: a
tool-free run is dead-lettered, because a budget refusal is no retryable failure
class, and a tool run settles as finished (ADR-162). A limit that
resets at midnight would otherwise burn one queued run per tick all night. So the
sweep calls Budget for the run's user and
configuration, and a run it refuses stays queued while the runs behind it
continue. This narrows the loss and does not close it: a run that passes the check
and spends the last of a budget while another does the same still meets the
refusal, and so does a run a Messenger consumer picks up, which asks nothing. A
consumer that left the row queued instead would have no way to learn when the
budget resets.
A retry waits for the next tick. On the synchronous transport the dispatch of a requeued run would execute it inside the failure handling of the run before it, and a rate limit would use up the retry budget in seconds. A run with an origin on that transport therefore stays queued, and the sweep is its backoff.
A scheduled job is a schedulable command, not a task class. The repository's
precedent is a console command the scheduler lists under
Execute console commands (nrllm:, nrllm:).
EXT:scheduler is not a dependency of this extension, and a task class would make
it one and tie the code to its API on two TYPO3 majors.
An AI Task runs as an ordinary run. nrllm: builds an
Agent from the task: an empty tool list, which the loop answers
with one plain completion; the task's configuration or the default; the task's
skills as forced skills; JSON mode for a JSON task; the task uid on the usage
row, which is why the tool-free turn now passes the response format and the task
uid. The queued task gets everything a run has and a Task execution does
not: a row, a status, a cost and a retry.
Whose rights. A background run acts as the actor stored when it was queued,
which Agent restores in the worker. For a task queued from
the command line that is a service account holding configuration:
(ADR-110). It is never derived from _cli_, whose admin flag
says nothing about who asked. A task calls no tool, so nothing here needs a
backend user. A producer that needs one, because its tool writes, stores the
frozen actor of the backend user who queued the work; a writing tool refuses a
service account. A deleted or disabled user yields no acting user in the worker
(Acting), so such a run's tools refuse.
Whose key. The actor decides what the tools may touch. The provider key is a
different question, and nr_vault asks it of the process: a console command has a
backend user that is not logged in, so its CLI rule applies, and that rule refuses
by default. Worker wraps the execution of a claimed run in
Technical for the backend user named by
worker., the way nr_ and nr_ do for
their own jobs. It is one setting and not the run's actor, because a service-account
run has no backend user, and because the key belongs to the installation and every
editor who queues work would otherwise need a grant on it. 0 keeps the CLI rule,
and a web request keeps its logged-in user, so nothing changes without the setting.
The scope covers the whole execution, so the delegated MCP credentials of a queued
run are stored as this user and not as the run's actor. Such a run failed on the
command line before as well, for lack of any actor.
A background run keeps its answer. The event stream follows
privacy. and keeps no content at the default metadata. An answer
nobody watched arrive is different: dropping it leaves a run nobody can use, so
a task would be queued and its result discarded. A run with an origin therefore
stores its answer in tx_, sealed like queued_
and rotated with it. none keeps nothing, redacted keeps the redacted
text, and the purge removes the column with the row
(privacy.). The detail page renders it for those who may
read the run; every other run stays metadata only. A run that goes through an
approval ends in the approver's request and keeps none: what it did is the write.
History. The module lists runs with an origin in every status, including
queued, restricted like the other lists. A service-account run carries
be_, so an administrator sees it and may open it, a holder of the
approval grant sees it and may not, and an editor does not see it.
Consequences
- An installation with cron or the scheduler can run background work with no Messenger consumer. One with a consumer needs no change.
nrllm:and a consumer may both pick a row; the claim decides.agent: work - An installation with only the sweep has no consumer heartbeat, so
nrllm:needsagent: status --.minimum- workers=0 - A service-account run has no backend user. The ambient
_cli_user can still appear on telemetry and guardrail rows of the run, which read it as the caller; nothing is authorised on it. - A run queued with
deferon a synchronous routing waits until the next tick. Nothing executes it if neither a sweep nor a consumer runs;() nrllm:reports that.agent: status -- max- queue- wait - The retry of a reaped run still goes through the message bus. On a synchronous routing the reaper executes it, as it did before.
Agentgained three methods and an optional parameter, which breaks out-of-tree implementors; the interface is in-repo plus test doubles by design (ADR-102). The additions to theRun Repository Interface @apisurface are additive.- A task's input is stored in the queued request until the run settles, sealed.
- Task input from a source is read when the task is queued, not when it runs.
Alternatives considered
Require routing the message to Doctrine. Then a producer on a default installation would have to refuse, and the two features this record exists for would not work out of the box.
A task-run table executing :php:`TaskExecutionService`. Full fidelity with the interactive path and no new behaviour in the loop, at the price of a second lifecycle. Rejected for the reason in ADR-162.
Keep the answer only at redacted or full. The default level would
then discard every result, and the page would show a scheduled task that
succeeded and said nothing.
Revisit when
- A backend form queues tasks: the actor is then the editor, and the list must
show them their own runs, which it already does by
be_.user - Queue wait measured by
nrllm:shows that a tick's limits do not keep up. The bounds are a judgement, not a measurement.agent: status