OpenAI Codex · Loop skeleton

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.

Course goalAfter this lesson you can explain three things. First: how many laps one user input takes in the task shell, the turn, and sampling — when each layer enters and exits. Second: which layer catches an interrupt, a tool follow-up, and a stop hook. Third: why pending is asked twice.
Play first · How many laps a sentence takes in three layers
Same input: watch which layer holds control, and how each layer’s count climbs
Script
Cut
Switch scripts to see which layer adds a lap. Merge into one layer and the interrupt and the hook fight over the same door.
0Task shell
0Turn
0Sampling
0Tools

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?

Waiting bench pending

Empty. Interrupts sit here first. The current sample cannot see them.

Tools in flight
Logic trail · each animation step maps to a source span
  1. If idle, spawn RegularTaskturn_input.rs L242
  2. Task shell emits TurnStarted, then enters the loopregular.rs L76
  3. At turn start, drain pending if the switch allowsturn.rs L305
  4. Sampling run_sampling_requestturn.rs L381
  5. A tool pins a future immediatelystream_events_utils.rs L326
  6. drain only after Completedturn.rs L2539
  7. Recompute needs_follow_upturn.rs L423
  8. A stop hook with a prompt continuesturn.rs L525
  9. Task shell asks has_pending_input againregular.rs L86
  10. If busy, Steered writes pendingturn_input.rs L207
Hit Play. Watch how many laps a sentence takes in three layers.
Teaching sketch: lap counts follow this preset script. Line numbers match openai/codex commit 4f39251a01.
Idea 1 · Cut by who gets to say continue
What problem it solves

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.

What the idea is

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

Task shell RegularTask Asks: is this trip still alive? Enter: after spawn. Exit: run_turn returns and the queue is empty. Turn run_turn Asks: does this turn sample again? Enter: the task shell calls. Exit: no follow-up and the stop hook lets go. Sampling sampling Asks: is this stream done? Enter: run_sampling_request. Exit: Completed and tools have drained. Retries stay on this layer. Cancel and stream break go Err. Control has not returned to the turn. TurnStarted fires once
Teaching diagram: The outer layer keeps this trip alive. The middle decides whether to sample again. The inner only finishes this stream.

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

Why it lasts

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.

Idea 2 · Ask pending twice, and pinch the stop hook between
What problem it solves

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.

What the idea is

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.

Sampling returns Tools already drained Turn asks pending Does this turn sample again? Has work: continue itself Task shell is still waiting on this run_turn Empty: run the stop hook block follows up; stop is what breaks Task shell asks pending again Does this trip enter run_turn again?
Teaching diagram: Two checks pinch the stop hook. They ask two questions at two moments.

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

Two checks pinch the stop hook. That’s why you ask twice.
Why it lasts

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.

Side-by-side · another cut of the same question

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.

Source-checked on both sides · 2026-08-22 · DSH · Inbox

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.

Source-checked on both sides · 2026-08-22
Classroom Exercise
01

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?

Takeaway: Write each layer’s stop condition as its own function. Interrupts only enter pending. The stop hook only enters the turn loop. The task shell looks at the queue only after the turn returns. Do not fold that into one while plus three booleans.