fix(agent): a failed turn must not bury the question it failed to answer - #267
Merged
yetone merged 1 commit intoSep 10, 2026
Merged
Conversation
The steer read-cursor advance lives in runAgentTurn's `finally`, guarded only
on `steeredMessageIds.size > 0`. So it ran on 'completed', on 'skipped', on
'failed' and on a thrown error alike.
On anything but 'completed' that advance is not an optimization, it is a
deletion. A steer arrives DURING the turn, so its (created_at, id) is later
than every message the turn was answering, and loadInbox compares one cursor
per conversation:
AND ROW(mm.created_at, mm.id) > ROW(co.lr_at, co.lr_id)
Advancing to the steer therefore sweeps past the turn's own unanswered inbox
as well. Ask a question, add a follow-up while the agent is working, let the
turn die at MAX_HOPS or on a provider 429: the room gets "Agent run failed
before it could finish. No result was produced." and BOTH messages are gone
from every future inbox. Nothing retries them. The human has to notice the
silence and re-ask.
The correlation is adverse rather than incidental: long multi-hop turns are
both the ones users interrupt with a follow-up and the ones that hit
MAX_HOPS.
It also contradicted the fingerprint contract twenty lines up in the same
file: "Failed turns do not update the fingerprint, so they remain retryable
instead of disappearing into a silent skip." Retryable work needs its inbox
rows to still exist. The comment on the advance reasoned only about failing
to mark ("markConversationRead is purely an OPTIMIZATION") — nobody had
reasoned about marking on a turn that produced nothing.
Gate it on `finalStatus === 'completed'`, which is the same line the
fingerprint already draws.
Two tests. The first drives a real turn that drains a steer and then dies,
and asserts both the follow-up and the question it interrupted are still in
loadInbox; it is verified red against the ungated advance. The second is the
guard rail: a completed turn must still consume the steer it drained, which
is what that code was added for.
This was referenced Sep 10, 2026
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What happens
You ask an agent something. While it is working you add a follow-up. The turn dies —
MAX_HOPS, a provider error, a 429.The room gets "Agent run failed before it could finish. No result was produced."
And then nothing. Not the follow-up, not the original question. Both are gone from every future inbox, the agent never retries either, and the only recovery is for you to notice the silence and re-ask both.
Why
The steer read-cursor advance sits in
runAgentTurn'sfinally, guarded only on whether anything was drained:A
finallyruns on'completed', on'skipped', on'failed', and on a thrown error alike.On anything but
'completed'that advance is not an optimization — it is a deletion, and it takes more than the steer with it. A steered message arrived during the turn, so its(created_at, id)is later than every message the turn was answering.loadInboxcompares one cursor per conversation:So moving the cursor onto the steer also moves it past the original question sitting behind it.
The correlation is adverse rather than incidental: long multi-hop turns are both the ones users interrupt with a follow-up and the ones that hit
MAX_HOPS.Two mechanisms in one file, disagreeing
Twenty lines above, the fingerprint contract:
lastCompletedInbox.set(...)is reached only underif (finalStatus === 'completed'). So the runtime's stated invariant is that a failure leaves the work re-doable — and the cursor advance was quietly deleting the rows that work is made of.The comment on the advance reasons only about the other direction:
That is true of failing to mark. Nobody had reasoned about marking on a turn that produced nothing, and there the same call is the delete key.
The fix
Gate the advance on
finalStatus === 'completed'— the same line the fingerprint already draws. One condition; the two mechanisms now agree.Tests
a failed turn leaves both the steer and the question it interrupted unread — drives a real turn that drains a steer and then dies, then asserts the cursor did not move onto the steer and that
loadInboxstill returns both messages. Verified red against the ungated advance:a completed turn still advances the cursor — the guard rail: without the advance a completed turn re-processes its own steer on the next wake, which is exactly what that code exists for. Green either way, on purpose.
Green:
tsc --noEmit(server + renderer),biome lint ., all threescripts/guard-*.mjs, the unit suite (1390 tests, 0 failures), and the fullagent-steersuite — 13/13, run twice cleanly. (An earlier run showed three unrelated steer tests failing; that was contention from my own overlapping test processes, not this change.mainand this branch both pass the suite when it has the database to itself.)Related
#266 fixes the notice this failure posts — that it claims "No result was produced" even where a result was. This one fixes what the failure does to the questions. They are independent and can land in either order.