Previously: Phleet Architecture Deep Dive — how the whole multi-agent system works.
You send a task to an agent. Ten seconds later you spot a mistake in your own message, so you send a correction.
What should happen next?
For a long time our answer was: nothing. The correction sat in a queue. The agent kept doing the wrong work, finished it, and only then read your fix. You could cancel the whole turn and start over — but you could not just say "wait, I meant this" and have it land.
This post is about closing that gap: what a turn is, and how you steer one that is already in flight.
First: what is a turn?
A turn is one complete cycle. Your message goes in. The model thinks, calls tools, reads files, runs commands. Then it produces one final answer. That whole thing, start to finish, is a turn.
Two facts about turns make this problem interesting.
A turn is not fast. It ends when the model decides it is done. If the agent is reading a large repository or waiting on a slow command, one turn can run for many minutes.
A turn holds a lock. Each of our agents runs one long-lived CLI process — Claude Code or Codex. A turn owns that process from start to finish. One turn at a time, per agent.
So when your correction arrives, the agent is not idle and waiting. It is busy, holding the only thing that can answer you.
Three things you can do with a late message
When a message arrives, there are only three sensible options.
Inject it. A turn is already running for this chat. Push the new message into that same turn, so the model sees it while it is still working.
Merge it. No turn is running yet, but an earlier message from the same chat is already waiting in the queue. Join them, so both are handled as one turn instead of two.
Queue it. Neither applies. Wait.
The interesting one is injection, because it depends entirely on what the CLI underneath will accept. Both Claude and Codex support it — in different ways, and in both cases the obvious-looking option is the wrong one.
Claude: just write to stdin
The Claude Code CLI reads newline-delimited JSON from standard input. If you write a second message while a turn is running, it folds into that same turn. One turn, one answer, and the model drops the instruction you replaced.
There is no acknowledgement event. Nothing confirms your message landed. The only proof is that the behaviour changes.
The protocol also has a priority field. It looks helpful. Both of its values are traps:
| value | what actually happens |
|---|---|
| not set | what you want — folds into the running turn |
"now" |
kills the turn, returns an empty result, starts a new one |
"later" |
not deferred at all — it starts its own turn immediately |
So the rule is: leave the field out. A field being accepted does not mean it does what its name suggests.
Codex: there is a real API for this
Codex has a purpose-built method for this: turn/steer. It is not experimental and not hidden — you can generate the protocol schema straight from the CLI and read it there, which is a better habit than guessing from your own call sites.
It behaves properly. We tested it against a turn that was running a 20-second shell command. The command kept its state, the same turn continued, and it finished with the steered answer.
It also takes an expectedTurnId. You pass the ID of the turn you think is running. If that turn already finished, or a different one started, you get an error instead of a message delivered to the wrong place.
That small parameter removed a whole piece of design. We had planned a per-chat lock to protect against that race. We did not need one — the protocol already solved it.
"Running" is not one state
Both mechanisms above ask the same question first: is a turn running? That question turns out to have a trap in it.
A turn does not stop being "running" the moment the model starts writing its answer. There is a gap at the end: the model has already committed to a final answer, but the turn has not formally closed yet.
A message that arrives in that gap is accepted by both CLIs. It is written to a live process. The write succeeds. And then it changes nothing, because the answer was already decided — so the text is dropped as stale when the next turn starts.
That is worse than a plain error, because nothing looks broken. The caller gets a confident "delivered" and the user simply never gets an answer.
So "is a turn running" is the wrong question. The right one is: can this turn still change its answer?
Each provider gives you a signal for it:
- Claude: the first assistant event that carries text and no tool call
- Codex: an item arriving with
phase == "final_answer"
After that point we refuse the injection and queue the message instead. Both gates are in ClaudeExecutor.cs and CodexExecutor.cs.
One rule worth stealing
Here is the part that generalises beyond agents.
We had a global gate: only one task runs at a time. And we had per-chat rules: inject, or merge with what is already queued.
The order of those two checks matters more than it looks. Put the capacity check first, and this happens: a turn ends, the running count drops to zero for a moment, and a newer message from the same chat sees free capacity and runs immediately — ahead of that chat's own earlier message, which is still queued.
A correction executing before the thing it corrects. Nothing in the logs looks wrong.
So: per-chat ordering must be checked before global capacity. Any system with a shared worker pool and per-conversation ordering has this bug available to it.
What it looks like now
Send a message to a busy agent and it reaches the running turn. Send three while it is queued and they arrive as one. Send one a moment too late and it waits for the next turn — quietly, because that is normal, not a failure.
The agent stopped being something you submit jobs to. It became something you can talk to while it works.
The work is in three issues if you want the details: #227 (injection), #230 (merging queued messages), and #235 (the gap at the end of a turn). The routing decision itself lives in TaskManager.cs. The rest of the source is at github.com/anurmatov/phleet.
Co-authored with Acto — my AI co-CTO and one of the agents described in this post.