-
Notifications
You must be signed in to change notification settings - Fork 0
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
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 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.
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.
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.)
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
textto a single pending catch-up slot and returnsNone. 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.
Core._start_turn (runtime.py:447) opens the turn:
-
Mint a
turn_id—uuid4().hex. This id is the turn's identity for its whole life. -
Open the fence —
self.fence.open(turn_id)(see the turn fence below). Only aResultcarrying this exact id will be accepted. -
Push the thinking face —
await self._push_face(FACE_THINKING)sends aSTATE_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. -
Spawn the worker —
await self.spawner.spawn_turn(turn_id, prompt). The spawner is the fork-server, injected (core never importsworker/— that keeps core LLM-free, AD-1). - 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.
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, thenos._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.
run_worker (shelldon/worker/worker.py:338) connects to the bus as Actor.WORKER, then:
-
Assembles the prompt —
build_prompt(message, ...)readsDIRECTIVE.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. -
Discovers self-coded tools —
build_tool_registry()loads any promoted tools. See Self-Coding Tools. -
Sends a
JOBto the broker:Envelope(kind=JOB, src=WORKER, dst=BROKER, body=Job(payload=...), turn_id=turn_id). The hub routesJOB → 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.
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 Completion — Envelope(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.
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```opsJSON block; a malformed block is rejected whole and left visible rather than silently swallowed. -
blurb— the model's shortTHOUGHT: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.
The RESULT lands on core_inbox and Core._handle_result (runtime.py:597) runs:
-
Fence —
if not self.fence.accept(env): return. A result whoseturn_idis not the open turn (late, zombie, superseded, or already-timed-out) is discarded with no side effects. -
Disarm the timeout — synchronously, before any
await, so it can't race the timeout firing. Thenfence.close(turn_id). -
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.
-
-
On failure:
_degrade()— a graceful "…can't think right now…" reply plus acant-thinkface. The turn still records. -
Release the guards —
await self._await_reap()(reclaim the worker, releasing the fork-server guard) thenarbiter.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. -
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 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 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)— returnsTrueonly if the result'sturn_idequals the current open turn. A closed, unknown, orNoneid returnsFalse→ 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.
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.)
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.
- 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.
shelldon — an E-Ink AI desk pet · docs generated from the project's design + implementation notes