# Runtime Turn State Machine

> v11 C1: a fixed reference of the per-turn state machine inside
> `src/query.ts::queryLoop`. The file is 1943 lines, but the state
> machine itself is small. This document pins the **shape** so
> reviewers and future refactors (the planned `src/runtime/turn-loop.ts`
> orchestrator) have a contract to match.
>
> Generated 2026-05-17. Tracks `clavue@8.10.18` / `claude-code@2.1.143`.
> `tests/turn-loop-state-machine-pin.test.mjs` grep-locks the state
> names against this document so they cannot drift silently.

---

## North star

Every assistant turn passes through the same six phases, in order.
The loop is allowed to **retry** by `continue`-ing back to PRE, but
the phase order within a single iteration is fixed.

```
   ┌──────────────────────────────────────────────────────────┐
   │                          turn N                          │
   │                                                          │
   │   PRE  ──▶  ROUTE  ──▶  DISPATCH  ──▶  TOOL-LOOP         │
   │                                          │               │
   │                                          ▼               │
   │                                       POST-TURN          │
   │                                          │               │
   │                          ┌───── continue ┤               │
   │                          │ (retry path)  │               │
   │                          │               ▼               │
   │                          │            RECOVER ──▶ exit   │
   │                          │                               │
   └──────────┬───────────────┘                               │
              │                                               │
              └──── continue ◀─── retry (next turn) ─────────┘
```

- **PRE**: snip / microcompact / autocompact / token budget / skill prefetch.
- **ROUTE**: build the runtime decision (provider × workflow mode × visible tools).
- **DISPATCH**: stream the API request, accumulate the assistant message.
- **TOOL-LOOP**: execute tool_use blocks, gather tool_result blocks.
- **POST-TURN**: attachments, verify gates, telemetry, queryCheckpoint('query_recursive_call').
- **RECOVER**: token-budget retry, max-output recovery, empty-post-tool retry, stop-hook retry.

Each phase has at least one `queryCheckpoint('query_<phase>_<edge>')`
landmark in the source — those are the anchors the pin test asserts.

---

## Phase landmarks (queryCheckpoint anchors)

These checkpoint strings exist in `src/query.ts` today. The pin test
asserts each one appears at least once.

| Phase | Entry checkpoint | Exit checkpoint |
|---|---|---|
| PRE (turn entry) | `query_fn_entry` | — |
| PRE (snip) | `query_snip_start` | `query_snip_end` |
| PRE (microcompact) | `query_microcompact_start` | `query_microcompact_end` |
| PRE (autocompact) | `query_autocompact_start` | `query_autocompact_end` |
| ROUTE+setup | `query_setup_start` | `query_setup_end` |
| DISPATCH | `query_api_loop_start`, `query_api_streaming_start` | `query_api_streaming_end` |
| TOOL-LOOP | `query_tool_execution_start` | `query_tool_execution_end` |
| POST-TURN (recurse) | — | `query_recursive_call` |

Renaming a checkpoint without updating this table or the pin test is a
deliberate change. The grep gate forces the author to think about it.

---

## Terminal exit reasons

`queryLoop` returns a `Terminal` value (see `Terminal` union in
`src/query.ts`). Each `return { reason: <X> }` is the loop's way of
saying "we're done with this query, not just this turn". The pin test
asserts the documented set matches the set found in source.

Documented reasons (alphabetical):

- `aborted_streaming` — user/abort signal hit while the API stream was open.
- `aborted_tools` — abort signal hit during tool execution.
- `blocking_limit` — policy limit blocks the turn before dispatch.
- `completed` — the model produced a final answer (no further tool_use).
- `heap_soft_block` — heap pressure recovery refused to spawn another turn.
- `hook_stopped` — a Stop hook returned `decision: "stop"`.
- `image_error` — image input could not be rendered (e.g. withheld media).
- `max_output_tokens` — recovery exhausted; surfaced as `model_error` in some sites.
- `max_turns` — `maxTurns` reached.
- `model_error` — non-recoverable API error (see `error` field).
- `prompt_too_long` — assembled prompt exceeded the model's max input.
- `stop_hook_prevented` — Stop hook blocked completion past the loop limit.

If a new `return { reason: ... }` is introduced anywhere in
`src/query.ts`, the pin test fails until this list is updated.
That's the gate.

---

## Continue / retry edges

The loop has **retry** edges (`continue`) inside a single query — they
go back to the top of the `while (true)` and start the next iteration
with updated `state`. These are NOT terminal:

- token-budget rebalance retry (after `applyToolResultBudget`)
- microcompact retry
- autocompact retry
- max-output-tokens recovery retry
- empty-post-tool-response recovery retry
- stop-hook loop retry
- successful tool execution → next assistant turn

The pin test does not enumerate these (they are control flow, not a
public contract) but the orchestrator extraction (planned
`src/runtime/turn-loop.ts`) MUST preserve every continue site one-for-one.
A diff that drops a continue is a semantic regression; alias-rename
diffs are fine.

---

## Why this document exists

`query.ts` is 1943 lines. New contributors take ~24h to read it. The
v11 C1 goal is to extract a ≤300-line orchestrator that schedules these
six phases without changing semantics. To make that refactor safe:

1. Pin the phase order and exit reasons here (this file).
2. Pin them in `tests/turn-loop-state-machine-pin.test.mjs`.
3. Diff against this contract during the extraction PR.

The contract is the gate. The source is the implementation. They must
stay in sync — that's the grep gate's job.

## PRE phase pure helpers (D3 step 1–5)

The PRE phase is the first one D3 has lifted out of `queryLoop`. The
following named exports in `src/runtime/turn-loop-pre.ts` are pure
functions — no yield, no async, no closure mutation, no UUID/clock
side effects (any randomness is **injected**, not imported). The
orchestrator extraction MUST call these by name rather than re-inline
the logic, and the unit test file `tests/turn-loop-pre.test.mjs`
pins their boundaries.

| Helper | Source-side call | Purpose |
|---|---|---|
| `shouldPersistContentReplacements` | `src/query.ts` content-replacement gate | `agent:` / `repl_main_thread` prefix → persist; else ephemeral |
| `selectUnboundedResultToolNames` | `src/query.ts` `applyToolResultBudget` skip-set | Tools with non-finite `maxResultSizeChars` opt out of the per-message budget |
| `nextAutoCompactTracking` | `src/query.ts` autocompact post-call tracking | Fresh / failure-only / pass-through branches; `mintTurnId` injected |
| `nextTaskBudgetRemaining` | `src/query.ts` PRE + TOOL_LOOP (twin sites) | `max(0, (prev ?? total) - finalContextTokens)` — single shared formula |
| `nextQueryChainTracking` | `src/query.ts` chainId/depth bookkeeping | First turn mints chainId; continuation increments depth |
| `buildAutoCompactSucceededEvent` | `src/query.ts` `tengu_auto_compact_succeeded` payload | Pure payload builder; `logEvent` side effect stays at call site |

If you add a new PRE-phase helper, add it to this table and to
`tests/turn-loop-pre.test.mjs` in the same commit. If you remove one,
do the inverse. The grep gate doesn't enforce this table — but the
extraction PR can `grep` it to know which surface is contract-frozen.
