Three-layer Turn Loop: who gets to say continue
What you see is one conversation turn. Inside, three loops stack: task shell, turn, sampling. Each layer answers only its own question. Control sits in one layer at a time.
Task shell RegularTask
Shift lead. Asks: is this trip still alive?
Turn run_turn
On-duty driver. Asks: does this turn sample again?
Sampling sampling
Ticket gate. Asks: is this stream done?
Empty. Interrupts sit here first. The current sample cannot see them.
- If idle, spawn RegularTaskturn_input.rs L242
- Task shell emits TurnStarted, then enters the loopregular.rs L76
- At turn start, drain pending if the switch allowsturn.rs L305
- Sampling run_sampling_requestturn.rs L381
- A tool pins a future immediatelystream_events_utils.rs L326
- drain only after Completedturn.rs L2539
- Recompute needs_follow_upturn.rs L423
- A stop hook with a prompt continuesturn.rs L525
- Task shell asks has_pending_input againregular.rs L86
- If busy, Steered writes pendingturn_input.rs L207
You ask a coding agent to change a function. On screen this is one turn: you said one thing, it worked a while, then replied “done.”
While it was busy, several things stacked. The model called tools. Mid-run you added “use pytest for tests.” After the assistant message, a Stop hook said the linter had not run, so it sampled again.
Cram those into one while and a few booleans fight over the exit. Who checks first, who can interrupt whom, becomes a verbal pact. Lose a layer and you lose a clean socket: interrupts, follow-ups, and stream retries tangle.
Codex splits into three layers. Task shell RegularTask::run decides whether this trip opens another turn. Turn run_turn decides whether a tool follow-up, an interrupt, or a hook continues. Sampling only takes one model stream to Completed.
The public entry start_or_steer_turn does not itself look at whether the session is idle. The return only says whether Core accepted the input. It waits for neither hooks nor sampling. Idle: spawn RegularTask. Busy: Steered writes pending. The three-layer loop starts only at the task shell.
Source: codex-rs/core/src/codex_thread.rs lines 333–344 · codex-rs/core/src/session/turn_input.rs lines 1–9
The task shell emits TurnStarted once, then calls run_turn again as long as the queue still has work. On the second entry next_input is empty; new messages come from input_queue. turn_id is pinned. The UI will not flash “a new turn started” again.
Source: codex-rs/core/src/tasks/regular.rs lines 76–90
The turn layer folds two facts from sampling into one boolean: model_needs_follow_up || has_pending_input. True: it continues itself. False: then it runs the stop hook. A hook with a prompt blocks clock-out, and this layer turns again. The task shell and sampling have not exited.
Source: codex-rs/core/src/session/turn.rs line 423 · lines 500–525
Sampling has two loops of its own. The outer handles retryable errors. The inner consumes one SSE stream. Tool calls do not wait for Completed: OutputItemDone pins a future immediately. When the stream hits Completed, it drain_in_flights first, then hands results back to run_turn.
Source: codex-rs/core/src/stream_events_utils.rs lines 326–327 · codex-rs/core/src/session/turn.rs lines 2539–2584 · line 2749
The three layers cut on “who gets to say continue.” Sampling sees only this stream: it can retry, it cannot close the turn. The turn sees tools, pending, budget, and hooks: it can sample again, it cannot re-emit TurnStarted. The task shell sees that the task is still here, and whether the queue still has work.
That does not change with how you split files. Rewrite in another language and the questions stay three: is this stream done, does this turn sample again, is this task trip still alive.
Both RegularTask and run_turn look at pending. Take the same user sentence in both places and it loops twice. Leave only one place and the hook and a late message squeeze through the same exit.
The two asks are two different questions.
The turn asks just after sampling ends: “does this turn sample again?” The task shell asks after run_turn has breaked: “does this trip enter run_turn once more?” The first keeps an interrupt and tool results in the same turn_id. The second is: the UI could clock out, and the queue got work that must run.
Drop the turn-layer ask: after the model writes a final answer, run_turn runs the stop hook and breaks. The mid-run “use pytest for tests” waits for the task shell to enter run_turn again. Functionally it still lands — one extra function return — but the stop hook runs a round before the interrupt reaches the model.
Drop the task-shell ask: after run_turn returns on should_stop, the task ends. A late user message in the queue either vanishes or waits for the session to go idle, then maybe_start_turn_for_pending_work opens a new task with a new turn_id. TurnStarted flashes again. Replay becomes two turn buckets.
A stop hook that returns block with a prompt leaves control in the turn layer. Sampling already returned. The task shell is still waiting on this run_turn. block is an in-turn follow-up. stop is the turn handing control back to the task shell.
Source: codex-rs/core/src/session/turn.rs lines 509–537 · codex-rs/hooks/src/events/stop.rs lines 67–74
The source never writes a standalone “why ask twice.” Infer it from the implementation. While a hook follows up, control stays in the turn; the task shell cannot see it. After the hook lets go, the task shell can take “the sentence that arrived at clock-out.” The two questions happen at different moments, so you ask twice. That does not depend on a language or a hook protocol.
DSH: cut by meaning into Turn, Step, Inbox
DSH’s outer ring is kick: while (await this.turn()) {}. turn() wraps another while (true), each lap preStep then step(). The third layer is not a third while. Inbox is two arrays: next-turn and next-step. The caller picks followup, steer, or inject at enqueue time.
Both say three layers. The cut is different. DSH cuts by meaning: Turn is a full unit of work, Step is one model request plus tools, Inbox is when you speak. Codex cuts by lifetime: the task shell asks if this trip is alive, the turn asks if this turn samples again, sampling asks if this stream is done. That is why DSH can replay two queues from session events. That is why Codex can lock stream retry, cancel, and end_turn inside sampling, and flash TurnStarted once.
Claude Code: one while plus a state bag
The main loop lives in query.ts. Mutable state sits in one state object, destructured at the top of the body, written back as a whole bag at each continue. needsFollowUp lights only from tool_use blocks in the assistant message. With no follow-up, the same layer does compaction, the stop hook, and the next turn. turnCount increments, transition becomes next_turn, back to the top of while (true).
Follow-up, compaction, and the stop hook share one state bag. Change one continue and you must re-check stopHookActive, turnCount, and transition together. Codex gives those three jobs to three layers. DSH gives interrupts to Inbox. Neither has to fight over one boolean.
Force the task-shell ask to always be false
While the model starts calling tools, you add “list the current directory too.” Walk the three layers first: how many laps for task shell, turn, sampling, and which layer takes this follow-up.
Then force has_pending_input on regular.rs line 86 to always be false, so the task shell ends on the first run_turn return. Does that follow-up vanish, wait for the next turn, or get a new turn_id from maybe_start_turn_for_pending_work?
while plus three booleans.