ADR-029: A booted process waits for the turn
Status: Accepted
Date: 2026-11-01
Context
ADR-002 runs every chat turn in a process started for it. Before the model is
called, that process boots TYPO3, which the reader waits for on every message and
every decision. ai-chat:worker existed as the alternative, but it never ended,
one worker ran one turn at a time, a turn waited for a worker that was down until
ai-chat:cleanup found it, and a long-lived process keeps what a turn left in
memory: Extbase's identity map, runtime caches, and the configuration it booted
with.
Decision
The boot moves in front of the turn, and the process still ends with it. A
worker boots, waits for a turn, runs it and ends; --max-turns=1 is the
recommended setting and its supervisor starts the next one at once. So the reader
no longer waits for the boot, and every turn still gets a fresh process: nothing
of a turn reaches the next one and memory cannot grow. The price is a boot after
each turn instead of before it, which nobody waits for, and a changed configuration
counts from the next boot, so at the latest after --time-limit. (Since the
round-cap amendment of ADR-023 of 2026-11-04, a worker stops before its next turn
when the settings were saved since it started.) TYPO3 reads the
time once at boot, so a worker sets it again when it takes a turn.
Keeping a process across turns stays possible and is not the default.
--max-turns=0 saves that boot and needs the state above to be safe. It would
also keep a connection to the provider open between turns, which saves little: in
six direct calls of a chat turn each, 0.98 s with a kept connection against 1.02 s
with a new one.
A turn never waits for a worker that is not coming. A worker reports itself
idle every two seconds in tx_nrmcpagent_chat_worker, and takes its row away
with a turn. A dispatch compares the idle workers with the turns no process holds
and, with too few, starts a process as exec does. So all workers being busy
costs a boot, a worker that is down costs a boot, and a deploy costs a boot. Both
may reach for the turn; the claim in dequeueForWorker() and
claimForProcess() lets one win.
The limits end a worker, not a turn. --time-limit and --memory-limit
take effect between turns. SIGTERM and SIGINT let a running turn finish.
--turn-limit is the one exception: a turn that exceeds it ends the process,
after the provider call it may be blocked in, which has a timeout of its own. A
supervisor's stop grace period above it lets a deployment wait for a running turn.
A turn is never run again by itself. A turn that was killed or exceeded the
limit may have written, and the duplicated pages of an earlier version came from a
second run over the same transcript. It fails with the state ai-chat:cleanup
leaves, without a pending decision, and the reader gets a Retry.
Consequences
- The reader waits for a poll of the queue,
--poll-intervalat most, instead of a boot. - Turns that run at the same time need as many workers. The rest are served by a process of their own, at the old speed.
- A deployment runs one more service per worker. The extension works without
any:
processingStrategystaysexecby default. - SIGTERM handling and
--turn-limitneedpcntl. Without it a worker dies on SIGTERM, in the middle of a turn if there is one, and a stuck turn waits forai-chat:cleanup. - An idle worker queries the database every
--poll-interval(200 ms unless set) and writes every 2 s.