What happened
On 2026-09-29 I ran the #1658 head de1977024 (the code on this path matches dev@c4f8ad26f and has not changed since dev@73ade0354). The command was codeaf chat --model deepseek/deepseek-v4-flash on the default engine road. A task started by the conversation was running, and the box was at load about 22 on 8 cores from parallel Go builds. I sent four large pastes one after another, each ending — reply with just OK., and each one printed:
› [paste 1 · 216 lines] — reply with just OK.
· submit failed: the engine did not answer in time
The engine took all four anyway. Each paste is in the conversation's transcript as a user message, and each was answered OK. One turn also ran a compaction that summarized three messages. The surface drew none of those four turns, neither the answers nor the compaction line. The fifth paste was acknowledged in time and drew normally.
So the person is told that a message failed when the model has actually read it, and they never see the turn it started. If they believe the note and send again, the model gets the message twice.
This is the same shape as #1528, which covers Task.Start, here for sending a message.
Replication
Deterministic (no model). Write an engine-road test in internal/remote that uses a stub model and an engine whose Submit handler returns its stream more than callDeadline (10 s) after the call arrives. A test clock or a hook in the handler will do. What happens today:
Agent.Submit on the client returns the engine did not answer in time.
- The engine then journals the message and runs the turn, and the stub's answer is in the engine's transcript.
- No event of that turn reaches the client's stream.
At the surface, adopt receives that error and writes submit failed: the engine did not answer in time.
Field (real models). Any model on the engine road will do: codeaf chat --model deepseek/deepseek-v4-flash, with OPENROUTER_API_KEY set, costing a few cents. Get a task running from the conversation, load the box heavily (for example stress-ng --cpu 0 or a few parallel go test runs), then send a few large pastes. It is timing dependent: it fired on 4 of 5 sends here and not at all on an unloaded box.
Where
internal/remote/callclass.go, classify. MethodSubmit falls through to classOrdered, so it waits behind anything else on the connection's ordered lane. The comment above the classes lists what the engine does before it answers Submit: it rebinds the client, re-reads standing orders, reassembles the system prompt, journals the message and starts the naming errand.
internal/remote/client.go, Agent.open via Client.call, bounded by callDeadline. A late answer comes back as ErrLate, and the stream the engine opens afterwards has no reader.
internal/tui3/app.go, adopt (search A MESSAGE THE ENGINE REFUSED NEVER HAPPENED). It treats every error as a refusal, including ErrLate, which only means this end stopped waiting.
The fix
A late Submit is not a failed one. My choice is to reconcile rather than simply wait longer, because a longer deadline only moves the edge.
- The surface gives each send an id, and
SubmitArgs carries it.
- On
ErrLate, the surface keeps the person's line marked as sending, not failed, and asks the engine by that id whether the message landed.
- If it landed, the surface takes that turn's stream, the way a steering submit rides the stream already being pumped, and draws the turn.
- If the engine never took it, today's refusal path runs: the line comes off and the draft and tray come back.
- A resend with the same id is a no-op on the engine, so a double press cannot put a message in twice.
submit failed: the engine did not answer in time stops appearing for a message the engine has taken.
Acceptance
- e2e: engine road with a stub model and an engine whose
Submit answers after the deadline. The surface never prints submit failed. The person's line is drawn once, the stub's answer appears under it, and the engine's transcript holds the message exactly once.
- e2e: the control. An engine that really refuses (a turn already running with no steering, or another window driving) still takes the line off and shows the engine's own sentence, as today.
- Unit: two
Submit calls with the same id add one user message to the conversation.
- The manual page that covers sending while the engine is busy (
internal/manual/chat/) says what a slow send looks like, and the change entry's invalidates names the old submit failed: the engine did not answer in time belief.
What happened
On 2026-09-29 I ran the #1658 head
de1977024(the code on this path matchesdev@c4f8ad26fand has not changed sincedev@73ade0354). The command wascodeaf chat --model deepseek/deepseek-v4-flashon the default engine road. A task started by the conversation was running, and the box was at load about 22 on 8 cores from parallel Go builds. I sent four large pastes one after another, each ending— reply with just OK., and each one printed:The engine took all four anyway. Each paste is in the conversation's transcript as a user message, and each was answered
OK. One turn also ran a compaction that summarized three messages. The surface drew none of those four turns, neither the answers nor the compaction line. The fifth paste was acknowledged in time and drew normally.So the person is told that a message failed when the model has actually read it, and they never see the turn it started. If they believe the note and send again, the model gets the message twice.
This is the same shape as #1528, which covers
Task.Start, here for sending a message.Replication
Deterministic (no model). Write an engine-road test in
internal/remotethat uses a stub model and an engine whoseSubmithandler returns its stream more thancallDeadline(10 s) after the call arrives. A test clock or a hook in the handler will do. What happens today:Agent.Submiton the client returnsthe engine did not answer in time.At the surface,
adoptreceives that error and writessubmit failed: the engine did not answer in time.Field (real models). Any model on the engine road will do:
codeaf chat --model deepseek/deepseek-v4-flash, withOPENROUTER_API_KEYset, costing a few cents. Get a task running from the conversation, load the box heavily (for examplestress-ng --cpu 0or a few parallelgo testruns), then send a few large pastes. It is timing dependent: it fired on 4 of 5 sends here and not at all on an unloaded box.Where
internal/remote/callclass.go,classify.MethodSubmitfalls through toclassOrdered, so it waits behind anything else on the connection's ordered lane. The comment above the classes lists what the engine does before it answersSubmit: it rebinds the client, re-reads standing orders, reassembles the system prompt, journals the message and starts the naming errand.internal/remote/client.go,Agent.openviaClient.call, bounded bycallDeadline. A late answer comes back asErrLate, and the stream the engine opens afterwards has no reader.internal/tui3/app.go,adopt(searchA MESSAGE THE ENGINE REFUSED NEVER HAPPENED). It treats every error as a refusal, includingErrLate, which only means this end stopped waiting.The fix
A late
Submitis not a failed one. My choice is to reconcile rather than simply wait longer, because a longer deadline only moves the edge.SubmitArgscarries it.ErrLate, the surface keeps the person's line marked as sending, not failed, and asks the engine by that id whether the message landed.submit failed: the engine did not answer in timestops appearing for a message the engine has taken.Acceptance
Submitanswers after the deadline. The surface never printssubmit failed. The person's line is drawn once, the stub's answer appears under it, and the engine's transcript holds the message exactly once.Submitcalls with the same id add one user message to the conversation.internal/manual/chat/) says what a slow send looks like, and the change entry'sinvalidatesnames the oldsubmit failed: the engine did not answer in timebelief.