Skip to content

fix(agent): a failed turn must not bury the question it failed to answer - #267

Merged
yetone merged 1 commit into
yetone:mainfrom
WhichPaths:fix/failed-turn-buries-its-own-inbox
Sep 10, 2026
Merged

fix(agent): a failed turn must not bury the question it failed to answer#267
yetone merged 1 commit into
yetone:mainfrom
WhichPaths:fix/failed-turn-buries-its-own-inbox

Conversation

@WhichPaths

Copy link
Copy Markdown
Collaborator

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's finally, guarded only on whether anything was drained:

if (steeredMessageIds.size > 0) {
  
  await runtime.markConversationRead({ agentId, conversationId, upToMessageId })

A finally runs 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. loadInbox compares one cursor per conversation:

AND ROW(mm.created_at, mm.id) > ROW(co.lr_at, co.lr_id)

So moving the cursor onto the steer also moves it past the original question sitting behind it.

09:00:00  Q1  "summarize the incident"        ← the turn's inbox
09:01:00  Q2  "and include the timeline"      ← steered mid-turn, drained at hop 3
          …hops 4-12… MAX_HOPS → finalStatus = 'failed'
          finally → markConversationRead(…, upToMessageId = Q2)
          cursor = (09:01:00, Q2)  →  Q1 and Q2 both excluded, forever

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:

Skip the LLM if this agent already completed exactly this inbox in their previous wake. Failed turns do not update the fingerprint, so they remain retryable instead of disappearing into a silent skip.

lastCompletedInbox.set(...) is reached only under if (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:

failures fall through to console.warn (the message stays in inbox; next wake re-reads it which is fine — markConversationRead is purely an OPTIMIZATION, not a correctness requirement)

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 loadInbox still returns both messages. Verified red against the ungated advance:

    the failed turn advanced the read cursor onto the steer, which buries the
    original question with it
    
  • 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 three scripts/guard-*.mjs, the unit suite (1390 tests, 0 failures), and the full agent-steer suite — 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. main and 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.

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.
@yetone
yetone merged commit 9fc56a5 into yetone:main Sep 10, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants