---
{
  "n": 66,
  "title": "A server that never makes reads wait",
  "abstract": "Write commands run in warm worker processes forked from the server, so reads such as the dashboard never wait behind a burst of commands",
  "refs": [
    "todo:1947",
    "todo:1949",
    "question:165"
  ],
  "seen": [
    "agent",
    "user"
  ],
  "data": {
    "environment": "main",
    "revisions": 1,
    "open_until": 1790423316.745285,
    "buttons": [
      {
        "label": "Accept this proposal",
        "say": "I accept the worker proposal (doc 66)",
        "choice": "answer"
      },
      {
        "label": "Change it first",
        "say": "I want changes to the worker proposal (doc 66) first",
        "choice": "answer"
      }
    ],
    "pressed": [
      "Accept this proposal"
    ]
  },
  "created": 1790421482.7404559,
  "updated": 1790789500.536573,
  "deleted": 0.0,
  "completed": 0.0,
  "outcome": "",
  "type": "doc"
}
---


## What happens now
The journal's server is one Python process. The engines that watch each session already run in processes of their own, so the server only answers requests: the viewer's reads (the dashboard, lists, a row) and the writes every journal command sends it through /api/run.

A write runs inside its request, together with every handler its events wake. That costs a board-filler command 50 to 100ms of work. Python runs one thing at a time inside a process, so while one command works, every other request, the viewer's dashboard included, waits for it. Measured:

- quiet: dashboard 8 to 22ms, a drafted card 6ms
- during a burst (the board-filler drafting while the viewer polls and the main agent works): dashboard up to about 400ms, card creation up to 137ms

Nothing is lost or wrong in a burst; everything just waits in one line.

## The change
**Commands run in warm worker processes; reads stay in the server.**

1. After the server has warmed up (it already loads every environment at boot), it forks two worker processes. A fork starts with a copy of the server's warm memory, so a worker needs no warm-up of its own.
2. /api/run hands each command to a free worker over a pipe and waits for its output; the server itself only passes the words along. The viewer's reads never enter a worker, so they never wait behind a command.
3. A worker runs the command exactly as the server does today: the command, its events and their handlers, writing to the same files.
4. The server's caches notice the new files the way they already notice a write from any other process: by the folder's stamp and its change log, then reading only what changed.
5. A worker that dies is replaced; a new build replaces all of them, as it does the server now.

Two workers let the board-filler and the main agent write at once without waiting on each other, while the viewer reads freely.

## What stays the same
- One port, one address: the viewer and every agent keep talking to the same server.
- The record on disk, its locks and its change logs: two processes already write to it today (a CLI run when the server is down, the engines), so it is built for that.
- Every command, its output and its refusals: the worker runs the same code the server runs now.
- The engines, the typist and the channel: untouched.

## Risks and how they are met
- **Two writers at once.** Every write already takes the record's file lock, and the event log is appended under it, so two workers cannot interleave a row or an event number.
- **Memory held only in a process.** Anything a handler keeps in memory rather than on disk would split between workers. The 'say it once' markers are already claims on disk; the rest (the dispatch-line caches, the typed-line window, the long-command clock) live in the engines, not the server. A sweep before building lists every module-level cache the command path writes and moves any that must be shared to disk.
- **A command that expects the server's own memory**, such as one that changes settings and expects the next request to see them. The settings file is re-read by its stamp, so the server sees a worker's change on its next read.
- **Forking a threaded process.** Workers are forked once, right after warm-up and before the request threads start, from a single thread, which is the safe point to fork.
- **A stuck worker.** Each command keeps today's time limit; a worker past it is killed and replaced, and the command answers with the timeout as it does now.

## Rollout
1. The sweep for in-memory state on the command path, and any move to disk it asks for.
2. The worker pool behind a setting (server.workers, 0 means today's behaviour), on here first with 2.
3. The generated suite runs every action through a worker as well as in-process, so both paths stay the same.
4. A day of normal use here, watching the budget warnings, then on in the other three journals, then on by default.

## How we know it worked
- During a board-filler burst, the dashboard stays under its 50ms budget; the faults feature stops reporting it.
- A drafted card still takes a few milliseconds of work, now in a worker.
- journal speed gains two rows: a read during a burst of commands, and a command through a worker.
- The whole suite, the boot test included, passes with workers on and off.
