# Turn Runner And Effect Interpreter
The turn runner is the small effect shell that drives one `Jidoka.Turn.Plan`
through the Runic spine and turns declared effects into real IO. The effect
interpreter is the lower half of that shell: it records every intent into the
journal, calls a runtime capability, and folds the result back into turn
state. This guide walks the loop end to end so contributors can change a phase
or add a checkpoint without breaking hibernation, replay, or controls. It is
written for people maintaining `Jidoka.Runtime.TurnRunner` and
`Jidoka.Runtime.EffectInterpreter`, not for agent authors.
## When To Use This
- Use this guide before reordering, adding, or removing a phase in
[`Jidoka.Runtime.TurnRunner`](`Jidoka.Runtime.TurnRunner`).
- Use this guide when changing how
[`Jidoka.Runtime.EffectInterpreter`](`Jidoka.Runtime.EffectInterpreter`)
records intents, replays results, or decides between calling a capability
and surfacing a review interrupt.
- Use this guide when introducing a new checkpoint policy or a new failure
mode that should produce a snapshot rather than an error.
- Do not use this guide as a tutorial on writing agents. Authors should read
[Getting Started](getting-started.md) and [Runtime And Harness](runtime-and-harness.md).
## Prerequisites
- Elixir `~> 1.18` and a checkout of the `jidoka` package.
- Familiarity with the pure spine described in
[Runic Spine Internals](runic-spine-internals.md).
- A mental model of `Jidoka.Effect.Intent`, `Jidoka.Effect.Journal`, and
`Jidoka.Turn.State`.
```bash
mix deps.get
mix test test/jidoka/runtime/effect_interpreter_test.exs
mix test test/jidoka/workflow_test.exs
```
## Quick Example
The smallest interesting view of the runner is a deterministic two-call loop:
the LLM asks for an operation, the operation answers, the LLM produces a final
content. Both capabilities are pure functions injected into
`Jidoka.Runtime.Capabilities.new/1`.
```elixir
alias Jidoka.Runtime.{Capabilities, TurnRunner}
alias Jidoka.Turn
spec =
Jidoka.agent!(
id: "runner_demo",
model: %{provider: :test, id: "m"},
operations: [%{name: "echo", description: "echo args"}]
)
{:ok, plan} = Jidoka.plan(spec)
{:ok, request} = Turn.Request.from_input("hello")
llm = fn _intent, journal ->
case Enum.count(journal.results, fn {_id, result} -> result.kind == :llm end) do
0 -> {:ok, %{type: :operation, name: "echo", arguments: %{"msg" => "hi"}}}
1 -> {:ok, %{type: :final, content: "done"}}
end
end
ops = fn %Jidoka.Effect.Intent{payload: %{"arguments" => args}}, _journal ->
{:ok, %{echoed: args}}
end
{:ok, capabilities} = Capabilities.new(llm: llm, operations: ops)
{:ok, %Turn.Result{content: "done"}} = TurnRunner.run(plan, request, capabilities)
```
No provider key was needed and no process was started. The runner reused the
same `Jidoka.Runtime.EffectInterpreter` path that a live ReqLLM turn uses; only
the capabilities changed.
## Concepts
Three ideas explain the runner's shape.
1. **Phase ordering is a contract, not a comment.** Each loop iteration runs
input controls (once), Runic planning, an optional checkpoint, operation
controls, effect interpretation, result apply, optional output controls,
and either loops or finishes.
2. **The journal is the source of truth for replay.**
[`Jidoka.Effect.Journal`](`Jidoka.Effect.Journal`) holds every intent and
every result keyed by intent id. The interpreter checks the journal before
calling a capability so resumed turns never repeat side effects.
3. **Hibernation is a runner decision.** Steps are hibernate-agnostic. The
runner decides whether the current point in the loop is a snapshot
boundary, based on checkpoint policy, pending interrupts, and the current
pending effect.
```diagram
╭─────────────────────────────╮
│ TurnRunner.run/4 │ effect shell
│ │
│ emit_turn_started │
│ Controls.run_input_controls│ ◀── once per turn
│ enforce_timeout │
╰─────────────┬───────────────╯
│
▼
╭────────────────────╮
│ run_loop │ ◀── once per model turn
│ (Runic workflow) │
╰─────────┬──────────╯
│
▼
╭────────────────────╮
│ maybe_hibernate │ ── checkpoint :after_prompt
│ _after_prompt │
╰─────────┬──────────╯
▼
╭────────────────────╮
│ maybe_hibernate │ ── checkpoint :before_each_effect
│ _before_effect │
╰─────────┬──────────╯
▼
╭────────────────────╮ ╭─────────────────────╮
│ EffectInterpreter │────▶│ run_operation │ ── may interrupt
│ .interpret_pending │ │ _controls │
╰─────────┬──────────╯ ╰─────────────────────╯
│
▼
╭────────────────────╮
│ Turn.State.apply │
│ _effect_result │
╰─────────┬──────────╯
│
╭────┴────╮
▼ ▼
:running :finished
│ │
│ ▼
│ output controls
│ emit turn_finished
▼ Turn.Result.from_turn_state!
loop_index + 1
```
Everything below grounds those three ideas in the actual functions in
[`Jidoka.Runtime.TurnRunner`](`Jidoka.Runtime.TurnRunner`) and
[`Jidoka.Runtime.EffectInterpreter`](`Jidoka.Runtime.EffectInterpreter`).
## How To
### Step 1: Read The Run Entrypoint
`TurnRunner.run/4` is the only sanctioned entrypoint for executing a plan:
```elixir
def run(%Turn.Plan{} = plan, %Turn.Request{} = request, %Capabilities{} = capabilities, opts \\ []) do
result =
with :ok <- Agent.Spec.validate_operation_policies(plan.spec),
state <-
Turn.State.new!(
spec: plan.spec,
plan: plan,
request: request,
agent_state: request.agent_state,
memory: Keyword.get(opts, :memory),
started_at_ms: clock_ms(opts)
),
:ok <- emit_turn_started(state, opts),
{:ok, state} <- run_and_emit(state, opts, &Controls.run_input_controls/1),
:ok <- enforce_timeout(state, opts) do
run_loop(state, capabilities, opts)
end
maybe_emit_turn_failed(result, plan, request, opts)
end
```
Three properties matter to contributors:
- **Operation policies are validated up front.** A spec with an
`:unsafe_once` operation without an operation control fails before any IO.
- **Input controls run exactly once** at the start, not once per loop
iteration.
- **`started_at_ms` is recorded once.** `enforce_timeout/2` compares against
this anchor at every phase boundary.
### Step 2: Walk One Loop Iteration
`run_loop/3` enforces the timeout, checks `max_model_turns`, compiles the
Runic workflow for the plan, drives it through Runic to completion, then hands
the planned state to the hibernation gate:
```elixir
defp run_loop(%Turn.State{loop_index: loop_index, plan: plan} = state, capabilities, opts) do
with :ok <- enforce_timeout(state, opts) do
if loop_index >= plan.max_model_turns do
{:error, {:max_model_turns_exceeded, plan.max_model_turns}}
else
workflow = Compiler.model_turn_workflow(plan)
planned_state =
workflow
|> Workflow.react_until_satisfied(state)
|> latest_state(:plan_model_effect)
emit_new_events(state, planned_state, opts)
maybe_hibernate_after_prompt(planned_state, capabilities, opts)
end
end
end
```
Three contracts matter:
- **The Runic graph is rebuilt per iteration.** It is cheap data, not a
process. Reusing it across iterations would require careful state reset.
- **`react_until_satisfied/2` is treated as opaque.** The runner reads only
the last `%Turn.State{}` produced by the named step `:plan_model_effect`.
- **Events emitted by steps are flushed immediately.** `emit_new_events/3`
diffs the event list between the pre-Runic and post-Runic states so trace
sinks see new events as they happen.
### Step 3: Decide Between Hibernate, Continue, And Error
The runner has two checkpoint gates after the workflow:
```elixir
defp maybe_hibernate_after_prompt(state, capabilities, opts) do
case checkpoint_policy(opts) do
:after_prompt -> hibernate(state, Turn.Cursor.after_prompt(), opts)
:after_each_phase -> hibernate(state, Turn.Cursor.after_prompt(), opts)
_policy -> maybe_hibernate_before_effect(state, capabilities, opts)
end
end
defp maybe_hibernate_before_effect(%Turn.State{} = state, capabilities, opts) do
with :ok <- enforce_timeout(state, opts) do
case {Turn.State.current_pending_effect(state), checkpoint_policy(opts)} do
{nil, _policy} ->
continue_after_pending_effect(state, capabilities, opts)
{%Effect.Intent{} = effect, policy} when policy in [:before_each_effect, :after_each_phase] ->
hibernate(state, Turn.Cursor.before_effect(effect), opts)
{%Effect.Intent{}, _policy} ->
continue_after_pending_effect(state, capabilities, opts)
end
end
end
```
The decision tree is intentionally narrow:
```diagram
checkpoint policy?
│
╭──────────────┼──────────────╮
▼ ▼ ▼
:after_prompt :before_each :after_each_phase
│ _effect │
│ │ │
▼ ▼ ▼
hibernate hibernate hibernate
│
▼
(call capability)
│
▼
:none continue → interpret_pending
```
A new policy must be added in `checkpoint_policy/1` and both `maybe_hibernate_*`
clauses. Anything else is treated as `:none`.
### Step 4: Read The Effect Interpreter
`EffectInterpreter.interpret_pending/3` is the lower half of the shell. It
inspects the journal first, only calls the capability for unseen intents,
and routes operation controls through `interpret_after_controls/5`:
```elixir
def interpret_pending(%Turn.State{} = state, %Capabilities{} = capabilities, opts) do
case Turn.State.current_pending_effect(state) do
%Effect.Intent{} = intent -> interpret_intent(state, intent, capabilities, opts)
nil -> {:error, Error.normalize(:missing_pending_effect, ...)}
end
end
defp interpret_intent(state, %Effect.Intent{} = intent, capabilities, opts) do
case Effect.Journal.result_for(state.journal, intent) do
%Effect.Result{} = result ->
{:ok, result, append_effect_trace(state, intent, :effect_replayed, [], opts)}
nil ->
with :ok <- validate_incomplete_effect_replay(state, intent) do
journal = Effect.Journal.put_intent(state.journal, intent)
state = %Turn.State{state | journal: journal}
state = append_effect_trace(state, intent, :effect_started, [], opts)
interpret_after_controls(state, intent, capabilities, journal, opts)
end
end
end
```
Three properties are load-bearing:
- **`Effect.Journal.result_for/2` is the replay gate.** If the journal already
has a result for this intent, the capability is never called again, no
matter what the policy is.
- **`validate_incomplete_effect_replay/2` is the `:unsafe_once` safety
rail.** When an `:unsafe_once` intent was recorded but never completed (for
example, the process crashed between `put_intent` and `put_result`), the
interpreter refuses to resume unless the intent carries an
`approved_interrupt_id` metadata key set by an approved review response.
- **The intent is written into the journal _before_ the capability is
called.** That guarantees a crash mid-call still leaves a recoverable trace.
### Step 5: Walk The Operation Control Branch
Operation controls only run for `:operation` effects. They can interrupt the
turn, in which case the runner snapshots and returns to the caller:
```elixir
defp run_effect_controls(%Turn.State{} = state, %Effect.Intent{kind: :operation} = intent, opts) do
event_count = length(state.events)
case Controls.run_operation_controls(state, intent) do
{:ok, %Turn.State{} = state} ->
emit_events(Enum.drop(state.events, event_count), opts)
{:ok, state}
{:interrupt, %Interrupt{} = interrupt, %Turn.State{} = state} ->
emit_events(Enum.drop(state.events, event_count), opts)
{:interrupt, interrupt, state}
{:error, reason} ->
{:error, Error.normalize(reason, operation: effect_operation(intent), ...)}
end
end
```
When the interpreter returns `{:interrupt, ...}`, the runner converts it to a
hibernation snapshot through `hibernate_for_interrupt/3`. The interrupt is
recorded on `Turn.State.pending_interrupt`, an `:approval_requested` event is
appended, and the snapshot uses `Turn.Cursor.review(interrupt)` as the cursor.
### Step 6: Resume A Hibernated Turn
`TurnRunner.resume/3` is the symmetric entrypoint. It loads `Turn.State` from
the snapshot and then branches on whether the state is awaiting approval:
```elixir
def resume(%AgentSnapshot{} = snapshot, %Capabilities{} = capabilities, opts \\ []) do
with {:ok, state} <- Turn.State.from_snapshot(snapshot) do
state
|> ensure_started_at(opts)
|> resume_from_snapshot(snapshot, capabilities, opts)
end
end
defp resume_from_snapshot(%Turn.State{status: :waiting, pending_interrupt: %Interrupt{}} = state, snapshot, capabilities, opts) do
case Review.approval_response(opts) do
:missing -> {:hibernate, snapshot}
{:ok, %Review.Response{} = response} -> resume_with_approval_response(state, ..., response, capabilities, opts)
{:error, reason} -> {:error, reason}
end
end
```
The hibernate-vs-error decision tree at resume:
```diagram
Turn.State status?
│
╭───────┼─────────────╮
▼ ▼
:waiting other status
pending_interrupt │
│ ▼
▼ continue_after_pending_effect
approval response? (re-interpret current intent)
│
╭────┼──────────┬────────────╮
▼ ▼ ▼ ▼
:missing invalid denied/ approved
│ response expired │
▼ ▼ ▼ ▼
hibernate {:error} {:error} apply response,
(noop) continue loop
```
`:missing` is the no-op path: a caller that resumes without supplying an
`:approval` option gets the same snapshot back. That is how external review
UIs poll without consuming the snapshot.
### Step 7: Handle Failures Without Losing Trace Events
Every error path passes through `maybe_emit_turn_failed/4` so a `:turn_failed`
event with `data.reason` is emitted before the caller sees the error tuple:
```elixir
defp maybe_emit_turn_failed({:error, reason} = result, %Turn.Plan{} = plan, request, opts) do
Event.build(:turn_failed, [],
agent_id: plan.spec.id,
request_id: request.request_id,
data: %{reason: inspect(reason)}
)
|> EventStream.emit(opts)
result
end
```
This is the only place that emits `:turn_failed`. Any new error branch must
flow through this helper, or trace consumers will not see the failure.
## Common Patterns
- **Always use `Turn.State.apply_effect_result/2` to fold capability output.**
It updates `pending_effects`, `agent_state`, `result`, and `status` together.
Mutating one field in isolation is a bug.
- **Emit events incrementally.** Use `run_and_emit/3` or compare event counts
before and after a step; never re-emit the full `state.events` list.
- **Keep all clock reads in `clock_ms/1`.** Tests inject `:clock` to make
`started_at_ms`, `responded_at_ms`, and `expires_at_ms` deterministic.
- **Treat the Runic workflow as the only producer of `pending_effects`.**
Hand-crafting an intent in the runner outside of `:plan_model_effect`
breaks deterministic test runs and the spine guarantees.
## Change Points
- **Checkpoint policies.** The runner reads `:checkpoint` from `opts`. New
policies must be added in `checkpoint_policy/1` and both `maybe_hibernate_*`
functions. Snapshot identity is supplied through `snapshot_opts/1`.
- **Capability normalization.** The runner accepts whatever
`Jidoka.Runtime.Capabilities.new/1` produces. New effect kinds (a third
capability slot) require adding a clause in
`EffectInterpreter.call_capability/3` and a field in `Capabilities`.
- **Approval providers.** `Jidoka.Runtime.Review.approval_response/1` controls
how an approval is sourced from `opts`. Wrapping it with a custom adapter
(for example, a database-backed approval queue) is the supported way to
integrate review UIs.
- **Operation controls.** New control behaviour returning
`{:interrupt, %Interrupt{}, state}` participates automatically; no runner
change is required.
## Invariants
Contributors must preserve every rule below. The rest of the runtime relies
on them.
1. **Intent before IO.** `Effect.Journal.put_intent/2` must run before
`call_capability/3`. Reversing the order makes crash recovery unsafe.
2. **Replay is content-addressed by intent id.** The journal lookup in
`Effect.Journal.result_for/2` is the only authority on "have we seen this
effect?". No phase may compare intents structurally.
3. **`:unsafe_once` requires explicit consent on replay.**
`validate_incomplete_effect_replay/2` must reject replays of incomplete
unsafe intents unless an approval response patched the intent metadata.
4. **`pending_interrupt` is set only by the runner.** Steps and capabilities
must not write to that field directly; they signal an interrupt by returning
from a control.
5. **`:turn_failed` is emitted exactly once per failed turn.**
`maybe_emit_turn_failed/4` is the only producer.
6. **`Turn.Result.from_turn_state!/1` is the only constructor for a finished
result.** The runner must not synthesize a `Turn.Result` from partial state.
7. **Resume never bypasses controls.** Approved intents continue through
`interpret_after_controls/5` so operation controls still see the (now
approved) intent.
8. **Snapshots are taken from a committed state.** `hibernate/3` appends
`:turn_hibernated` to the state before serializing, so the snapshot already
contains the hibernation event.
## Testing
Two patterns cover most contributor changes to the runner and interpreter:
deterministic loop tests and journal-replay tests.
```elixir
test "interpreter records intent and replays journal on second call" do
alias Jidoka.Effect
alias Jidoka.Runtime.{Capabilities, EffectInterpreter}
alias Jidoka.Turn
spec = Jidoka.agent!(id: "interp", model: %{provider: :test, id: "m"})
{:ok, plan} = Jidoka.plan(spec)
{:ok, request} = Turn.Request.from_input("hi")
state =
Turn.State.new!(
spec: plan.spec,
plan: plan,
request: request,
agent_state: request.agent_state,
pending_effects: [Effect.Intent.new(:llm, %{prompt: %{}})]
)
llm = fn _intent, _journal -> {:ok, %{type: :final, content: "ok"}} end
{:ok, capabilities} = Capabilities.new(llm: llm, operations: fn _i, _j -> {:error, :unused} end)
{:ok, %Effect.Result{status: :ok}, state} =
EffectInterpreter.interpret_pending(state, capabilities)
# second call replays from journal; capability is never called again.
{:ok, %Effect.Result{status: :ok}, _state} =
EffectInterpreter.interpret_pending(state, capabilities)
end
```
For runner-level tests, prefer `Jidoka.Runtime.TurnRunner.run/4` with the
the helpers in `test/support/test_support.ex`. Use
`Jidoka.Trace.timeline/1` over raw events so trace ordering changes
do not break unrelated assertions.
## Troubleshooting
| Symptom | Likely Cause | Fix |
| --- | --- | --- |
| `{:error, :missing_pending_effect}` from the interpreter | A step did not append an `Effect.Intent` to `pending_effects` | Ensure the Runic graph ends at `:plan_model_effect` and returns a state with a pending intent. |
| `{:error, {:max_model_turns_exceeded, n}}` | The loop ran past `plan.max_model_turns` without producing `:final` | Tighten the prompt or raise `max_turns` in the agent's `controls`. |
| `{:error, {:turn_timeout_exceeded, ms, elapsed}}` | A capability blocked past `plan.timeout_ms` | Lower capability latency or raise `timeout_ms` in `controls`. |
| Capability is called twice for the same intent | Code path bypassed `Effect.Journal.result_for/2` | Route the new path through `EffectInterpreter.interpret_pending/3`. |
| Resume immediately returns the same snapshot | `:approval` not supplied to `Jidoka.resume/2` | Pass `approval: %Jidoka.Review.Response{...}` (or `approval_response:`). |
| `:turn_failed` event missing in trace | Error returned outside `maybe_emit_turn_failed/4` | Route the error tuple through the helper before returning it. |
| Snapshot deserialization fails after a code change | A new field on `Turn.State` is not portable | Use `Jidoka.Runtime.AgentSnapshot.serialize/1` in tests; the portable validator will name the offending key. |
| Approval response rejected with `:approval_interrupt_mismatch` | Wrong `interrupt_id` on the response | Look up the latest `Interrupt.id` from `Turn.State.pending_interrupt` or the `pending_review` metadata on the snapshot. |
## Reference
- [`Jidoka.Runtime.TurnRunner`](`Jidoka.Runtime.TurnRunner`) - phase loop,
checkpoints, timeout enforcement, resume.
- [`Jidoka.Runtime.EffectInterpreter`](`Jidoka.Runtime.EffectInterpreter`) -
journal-aware capability dispatch.
- [`Jidoka.Runtime.Capabilities`](`Jidoka.Runtime.Capabilities`) - typed
capability bundle the runner consumes.
- [`Jidoka.Runtime.Controls`](`Jidoka.Runtime.Controls`) - control runtime
used at input, operation, and output boundaries.
- [`Jidoka.Runtime.Review`](`Jidoka.Runtime.Review`) - approval validation and
application during resume.
- [`Jidoka.Effect.Journal`](`Jidoka.Effect.Journal`) - append-only intent/result
store keyed by intent id.
- [`Jidoka.Turn.State`](`Jidoka.Turn.State`) - per-turn accumulator the runner
threads through every phase.
- [`Jidoka.Turn.Cursor`](`Jidoka.Turn.Cursor`) - cursor values used at
hibernation points.
## Related Guides
- [Runic Spine Internals](runic-spine-internals.md) - pure workflow steps the
runner drives.
- [Runtime Capabilities Internals](runtime-capabilities-internals.md) - how
`Capabilities`, ReqLLM, and operation adapters fit together.
- [Projection Internals](projection-internals.md) - the stable shapes the
runner's events and snapshots expose to consumers.
- [Troubleshooting](troubleshooting.md) - error categories that map back to
the runner and interpreter.