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-interval at 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: processingStrategy stays exec by default.
  • SIGTERM handling and --turn-limit need pcntl. Without it a worker dies on SIGTERM, in the middle of a turn if there is one, and a stuck turn waits for ai-chat:cleanup.
  • An idle worker queries the database every --poll-interval (200 ms unless set) and writes every 2 s.