Skip to content

How a Turn Works

Elliot Boney edited this page Jun 23, 2026 · 2 revisions

How a Turn Works

A turn is the unit of life in shelldon: one owner message in, one reply out, and a face that reacts on the E-Ink panel. This page traces the full lifecycle of a single turn — from the moment a message lands on the bus to the moment the worker process dies and its RAM is reclaimed.

If you have not yet read the Architecture overview or The Brain, start there — this page assumes you know the five actors (core, broker, worker, transport, display) and that they talk only over the envelope bus.

Related pages: Architecture · The Brain · Memory and Learning · Self-Coding Tools

The cast

Every turn moves through five processes, each addressing the others over one Unix-domain-socket bus that core hosts and routes:

Actor Role in a turn Code
Transport Receives the owner's message (Telegram / CLI), emits the reply shelldon/transport/
Core The turn orchestrator + sole writer of state. Fences turns, runs the arbiter, pushes faces, applies proposed changes shelldon/core/runtime.py
Fork-server Warm parent that os.fork()s exactly one worker per turn and reaps it shelldon/worker/forkserver.py
Worker The ephemeral brain adapter: assembles the prompt, calls the LLM via the broker, parses the reply shelldon/worker/worker.py
Broker Pure egress: the only process that holds credentials and talks to the provider shelldon/broker/
Display Renders the latest face + caption pushed by core shelldon/display/

Core is special: it is both the bus hub (it routes every envelope) and a bus destination (Actor.CORE). A RESULT bound for core is delivered to an in-process queue, never over a socket.

The one-paragraph version

The owner's message arrives as an INBOUND_MSG envelope on core's inbox. Core's arbiter decides whether to start a turn now or coalesce the message into a pending slot (because at most one worker may run at a time). To start a turn, core mints a turn_id, opens the turn fence on it, pushes a thinking face, and asks the fork-server to fork a worker. The worker assembles a prompt, sends a JOB to the broker, and the broker calls the LLM. The completion comes back to the worker (not core), which parses the reply into a structured Result — user-facing text plus a list of proposed ops plus a screen thought and a chosen face — and sends that RESULT to core. Core fences the result against the open turn_id, delivers the reply over the transport, swaps the thinking face for the reaction face + caption, then validates and applies the proposed ops as the sole writer. The worker exits; its RAM is reclaimed; the arbiter slot frees and any coalesced message starts the next turn.

Step-by-step sequence

  OWNER                                                                  OWNER
   │                                                                       ▲
   │ "remember I love hazelnut coffee"                                     │ reply
   ▼                                                                       │
┌───────────┐                                                       ┌───────────┐
│ TRANSPORT │  INBOUND_MSG ─────────────────┐         ┌──────────── │ TRANSPORT │
└───────────┘                               │         │ OUTBOUND_MSG└───────────┘
                                            ▼         │
                                      ┌──────────────────────┐
                              ┌──────▶│        CORE          │──────┐ STATE_SNAPSHOT
                              │       │  (hub + orchestrator) │      │  (FACE / CAPTION)
                              │       └──────────────────────┘      ▼
                              │          │  ▲        ▲          ┌──────────┐
                              │ RESULT   │  │        │ EVENT    │ DISPLAY  │
                              │          │  │ core   │          └──────────┘
                              │          │  │ inbox  │
                              │          ▼  │        │
                              │   ┌──────────────┐   │
                              └───│  FORK-SERVER │   │
                                  │  os.fork() ×1│   │
                                  └──────────────┘   │
                                         │ forks     │
                                         ▼           │
                                  ┌──────────────┐   │
                                  │   WORKER     │   │
                                  │ assemble +   │───┘ RESULT (payload + proposed_ops
                                  │ parse reply  │           + thought + face)
                                  └──────────────┘
                                         │ JOB        ▲ COMPLETION
                                         ▼            │
                                  ┌──────────────┐
                                  │   BROKER     │──▶ LLM provider
                                  │ (egress only)│◀──
                                  └──────────────┘

Now the detailed flow, with the real call sites.

1. Inbound message reaches core

The transport receives the owner's message and writes an Envelope(kind=INBOUND_MSG, dst=CORE, body=Message(text=...)) to the bus. The hub routes it by kind — ROUTING_TABLE[INBOUND_MSG] = CORE — into core's in-process core_inbox queue.

Core runs a single consumer loop (Core.run, runtime.py:405):

while True:
    env = await self.bus.core_inbox.get()
    if env.kind is MsgKind.INBOUND_MSG:
        ...
        prompt = self.arbiter.submit(env.body.text)
        if prompt is not None:
            await self._start_turn(prompt)
    elif env.kind is MsgKind.RESULT:
        await self._handle_result(env)
    ...

Because this is the only task that touches the arbiter and the fence, turn admission is serial — no lock is needed. (An INBOUND_MSG that carries an approval_turn_id is a tap on an Approve/Deny button, not chat — it routes to the approval path instead. See Self-Coding Tools.)

2. The arbiter: start now, or coalesce

The arbiter (shelldon/core/arbiter.py) enforces the system's central constraint: at most one worker turn in flight at a time (AD-9). This is why shelldon stays inside the Pi Zero's RAM budget and never accumulates memory across turns.

arbiter.submit(text) has two outcomes:

  • No turn running → it reserves the slot and returns the prompt. Core calls _start_turn(prompt).
  • A turn is already running → it appends text to a single pending catch-up slot and returns None. The message is never dropped — when the current turn finishes, the pending text folds into exactly one follow-up turn.

The key property: a burst of messages during a slow turn does not spawn a backlog of turns. They all coalesce into one next turn (the texts joined with newlines). One in-flight + one pending = at most two turns total, never two at once.

3. Starting the turn: fence, face, fork

Core._start_turn (runtime.py:447) opens the turn:

  1. Mint a turn_iduuid4().hex. This id is the turn's identity for its whole life.
  2. Open the fenceself.fence.open(turn_id) (see the turn fence below). Only a Result carrying this exact id will be accepted.
  3. Push the thinking faceawait self._push_face(FACE_THINKING) sends a STATE_SNAPSHOT(region=FACE, face="thinking") to the display, plus a working caption. The pet visibly starts thinking before the LLM is even called. A face push is cosmetic, so it is guarded — a display hiccup never aborts the turn.
  4. Spawn the workerawait self.spawner.spawn_turn(turn_id, prompt). The spawner is the fork-server, injected (core never imports worker/ — that keeps core LLM-free, AD-1).
  5. Schedule the reap and arm the turn timeout (both covered below).

If spawn_turn itself raises (e.g. os.fork() returns ENOMEM), core closes the fence and resets the arbiter so the guards release — otherwise every future message would silently coalesce into a slot that never flushes.

4. The fork-server forks one worker

The fork-server (shelldon/worker/forkserver.py) is a warm parent process that pre-imported the heavy LLM libraries, then called gc.disable() + gc.freeze() before any fork. On spawn_turn, it os.fork()s exactly one child. The child:

  • Inherits the warm, frozen libraries via copy-on-write — so the import cost is paid once, not per turn.
  • Runs run_worker(...) to completion, then os._exit(0).
  • Reclaims all its RAM on exit. Nothing accumulates across turns. This is the fix for v1's out-of-memory death.

The fork-server holds its own mechanical worker_in_flight flag as a backstop, but in normal flow the arbiter already serializes requests, so the two ≤1 guards never conflict.

On the Pi, the forked child must close inherited SQLite connections before it does anything (_close_inherited_sqlite()), because SQLite is not fork-safe. See Memory and Learning for that war story.

5. The worker assembles the prompt and sends a JOB

run_worker (shelldon/worker/worker.py:338) connects to the bus as Actor.WORKER, then:

  1. Assembles the promptbuild_prompt(message, ...) reads DIRECTIVE.md, about.md, and recent history read-only and composes them in order. The worker reads state; it never writes it (AD-5). See Memory and Learning.
  2. Discovers self-coded toolsbuild_tool_registry() loads any promoted tools. See Self-Coding Tools.
  3. Sends a JOB to the broker: Envelope(kind=JOB, src=WORKER, dst=BROKER, body=Job(payload=...), turn_id=turn_id). The hub routes JOB → BROKER.

The worker stays connected and waits for the broker's answer. It does not read or write any pet state, and it never holds credentials.

6. The broker calls the LLM and returns a COMPLETION

The broker (shelldon/broker/) is the only process with provider credentials, and the only one that talks to the network. It receives the JOB, runs the provider chain (with a single retry), and returns the raw text/error to the worker as a CompletionEnvelope(kind=COMPLETION, dst=WORKER). The hub routes COMPLETION → WORKER.

Critically, the broker does no pet-domain parsing (AD-2). It relays text and errors. Turning a reply into structured changes is the worker's job, not the broker's. A failure surfaces as a value (Completion(ok=False, error=...)), never an exception across the bus.

7. The worker parses the reply into a Result

When the worker reads the Completion, parse_reply (worker.py:147) splits the raw text into four parts:

  • payload — the user-facing reply, with all control blocks stripped out.
  • proposed_ops — a closed, typed list of changes the model wants to make: Remember, RewriteAbout, AddFace, CaptureLearning, RequestToolApproval, and friends. Encoded by the model as a fenced ```ops JSON block; a malformed block is rejected whole and left visible rather than silently swallowed.
  • blurb — the model's short THOUGHT: line for the E-Ink caption strip.
  • face — the expression the model picked as its reaction (FACE: line), validated against a known palette.

Reasoning-model scaffolding (<think>…</think>) is stripped first so private chain-of-thought never leaks into the reply or the screen.

For a turn that uses tools, the worker runs a bounded function-calling loop (_agentic_loop, capped at _MAX_TOOL_EXECUTIONS = 6 rounds and the same time budget) — executing FREE-tier tools and looping until the model produces text. A RISKY-tier tool call pauses the turn and emits a RequestToolApproval instead of blocking on a human. See Self-Coding Tools.

The worker then sends Envelope(kind=RESULT, src=WORKER, dst=CORE, body=Result(...), turn_id=turn_id) — stamping the turn's own id so core can fence it — and exits. The hub routes RESULT → CORE.

Topology note. The worker, not the broker, emits the Result→core. The broker stays a pure egress boundary. This is the write-back wire: the worker proposes changes; core applies them.

8. Core fences, replies, reacts, and applies

The RESULT lands on core_inbox and Core._handle_result (runtime.py:597) runs:

  1. Fenceif not self.fence.accept(env): return. A result whose turn_id is not the open turn (late, zombie, superseded, or already-timed-out) is discarded with no side effects.
  2. Disarm the timeout — synchronously, before any await, so it can't race the timeout firing. Then fence.close(turn_id).
  3. On success:
    • _send_reply(payload)OUTBOUND_MSG → CHAT_TRANSPORT. The owner gets the reply. (If the reply is an approval request, the outbound is tagged so the transport renders Approve/Deny.)
    • _push_face(reaction_face) → swaps the thinking face for the model's chosen reaction face (or the default reply face if the pick is unknown).
    • _push_caption(thought, dwell=…) → the screen thought, held for a dwell period so it lingers instead of flashing on the slow E-Ink panel.
    • _apply_proposed_ops(...) → core validates and applies each op as the sole writer (AD-5). Memory ops write the curated markdown tree; learnings go to SQLite; face ops edit the registry; an over-cap or invalid op is logged and skipped — one bad op never crashes the turn, and the reply is already delivered.
    • _record_turn(...) → the (owner, pet) pair is written to history.
  4. On failure: _degrade() — a graceful "…can't think right now…" reply plus a cant-think face. The turn still records.
  5. Release the guardsawait self._await_reap() (reclaim the worker, releasing the fork-server guard) then arbiter.complete() (release the arbiter slot), in that order so a catch-up turn can't hit a freed arbiter while the fork is still held.
  6. Drive the catch-up — if arbiter.complete() returned folded pending text, core calls _start_turn(folded) and the cycle repeats exactly once.

The ordering is deliberate: reply and react first, apply changes second. The owner never waits on a memory write, and a failed write never blocks the reply.

The thinking → reaction face transition

The face is a small state machine, and the panel is slow E-Ink, so transitions are managed carefully:

Moment Face pushed Caption
Turn starts (_start_turn) thinking (working)
Result OK (_handle_result) model's reaction face, e.g. happy/curious/grumpy the THOUGHT: line, held for a dwell
Result failed / timed out (_degrade) cant-think a degrade caption
Between turns (reflex tick) the at-rest mood face the mood token

The reaction face + thought hold the screen for _REACTION_DWELL_S (60s) against the ambient mood drift, so a deliberate expression doesn't get instantly overwritten by the next reflex tick — without that hold, an early reflex would erase the reply face so it only flashed. After the dwell, the at-rest mood is allowed to take over. Identical pushes are deduplicated so the panel doesn't re-flash. Core stays display-agnostic: it pushes face tokens; the display owns the actual artwork.

The turn fence: fencing and idempotent close

The fence (shelldon/core/turn.py, TurnFence) is core's guarantee that exactly one turn's result is ever acted on. It holds current_turn_id and a bounded set of recently closed ids:

  • open(turn_id) — set the current turn.
  • accept(result_env) — returns True only if the result's turn_id equals the current open turn. A closed, unknown, or None id returns False → discard.
  • close(turn_id) — move current → closed; idempotent (closing twice is safe).

This is what makes a late result harmless. If a worker is slow and the turn times out, core closes the fence; when the late result finally arrives, accept returns False and it is dropped — no double reply, no second turn from a stale result.

Timeouts and graceful degrade — never hang

A turn must never hang the pet. Three nested timeouts enforce this, ordered W < R < T (worker self-report < reap SIGKILL < core degrade):

Timer Where Default What it does
W — completion timeout worker.py _COMPLETION_TIMEOUT_S 25s The worker waits at most this long for the broker. On timeout it emits Result(ok=False) and exits, rather than blocking forever (which would never reap and would wedge the ≤1 slot).
R — reap timeout forkserver.py _REAP_TIMEOUT_S ~28s The fork-server SIGKILLs a wedged child so the reap (and the mechanical guard) always returns.
T — turn timeout runtime.py DEFAULT_TURN_TIMEOUT 30s Core's backstop. If no result is accepted in time, _timeout_watch (runtime.py:680) closes the fence, calls _degrade(), reaps, releases the arbiter, and may start the coalesced next turn.

Because W < R < T, the worker self-reports a failure before core abandons the turn, the fork is reclaimed before core's deadline, and the arbiter slot and fork-server guard free in lockstep. The inverted ordering (the historical bug: a 120s worker vs a 30s core) is what let a silent broker hold the fork ~90s past core's degrade and freeze every new turn — hence the strict ordering today.

The graceful state (_degrade) is the same whether the trigger is a failure result or a timeout: a "…can't think right now…" reply plus a cant-think face. The pet always says something and always frees its slot. (Full provider-chain fallback and reflex behavior are layered on top — see The Brain.)

The worker dies — and that's the point

After the worker sends its Result, it closes its bus connection and exits. The fork-server reaps it (os.waitpid, SIGKILL-escalated at the R deadline). The child's entire address space — the warm libs' dirtied pages, the prompt, the LLM response — is reclaimed by the OS.

This is the architecture's core bet: one fork per turn, then die. Nothing leaks between turns. RAM is bounded by a single worker's footprint, not by an ever-growing long-lived process. On the 416 MB Pi Zero 2W this is the difference between a pet that runs for months and v1, which OOM-killed itself.

Where to go next

  • Architecture — the five actors, the envelope bus, and the binding invariants (AD-*).
  • The Brain — the provider chain, the arbiter's budget/cooldown/battery gating, and the reflex loop that drifts the mood between turns.
  • Memory and Learning — how proposed ops become the curated markdown tree and the SQLite learnings table, plus the dream cycle.
  • Self-Coding Tools — the bounded tool loop, RISKY-call approval pauses, and how the pet writes and gates its own tools.

Clone this wiki locally