Skip to content

fix(webui): reconcile a turn that outlives the client's own response stream - #249

Merged
modacker merged 1 commit into
webuifrom
fix/webui-silent-early-exit
Oct 24, 2026
Merged

modacker merged 1 commit into
webuifrom
fix/webui-silent-early-exit

Conversation

@modacker

@modacker modacker commented Oct 24, 2026 •

Copy link
Copy Markdown
Collaborator

One product bug, reproduced against the real runtime before anything was changed, with four unit cases — three of which fail on the trunk and pass here.

The bug: a response stream that ends was taken for the end of the turn.

A stream that ends says the runtime closed it — not that it finished the turn. The runtime may close the stream early, and the queue may move on to the next turn while this one is ending. The client had no way to tell the two apart: the end of its own stream became phase: "done", the sending flag came down, no refusal was raised, and the turn that was still running was never followed. The answer to "is a turn still live?" was available the whole time — the runtime's active-turn read — and the command set already used it for a session.start it never saw. A local send simply never asked; only the attach path reconciled.

The fix: a send now reconciles the session it landed on once its own stream is done. The same owner the event path uses asks the runtime and re-attaches to whatever is still running. The session to reconcile is the one the send landed on, not the one the command set was built for — a first message creates the session it is sent to and the record moves onto it — so the application resolves it from the completion.

A queued submission is left alone: it joined somebody else's turn and never had a stream of its own to end, and the turn it joined is somebody else's stream, which the queue's own events speak for.

The obvious shortcut was to probe only when the stream ended without the runtime's terminal frame, which would have kept the common case free of a round trip. Measured against the real runtime, that shortcut is wrong. A send whose turn ended while the queue moved on to the next turn still carried its own session_status:finished frame — every frame on the stream belonged to that one turn — while the runtime's active-turn read reported the following turn as live. The terminal frame says this turn is over and nothing about the next one, so "did the stream carry a terminal frame" and "is anything still running on this session" are two different questions, and guarding the probe on the first one loses the queued case entirely.

Two further facts came out of the same measurement: the local send's [DONE] arrives after the turn has settled in the database (8 of 8 rounds, the probe already answered "no active turn" by then), so the retirement window is closed before the client can observe it; and a normal round therefore pays one probe that answers "nothing is running". That cost is the price of the only question that can be asked of the runtime, and it is the form that was approved.

The cases that pin this are packages/webui/test/unit/conversation-turn-commands.test.ts — the send hands over the session it landed on; a first message hands over the session it created rather than the home slot; a queued submission hands over nothing — and packages/webui/test/unit/webui-application.test.ts — the whole shape end to end: the send's own stream ends, the runtime says a turn is still running, and the client attaches to it. The three that assert the reconciliation fail on the trunk (the owner is never asked, the runtime's read is never made); the queued one holds on the trunk and holds here.

…stream

A response stream that ends says the runtime closed it — not that it finished
the turn. The runtime may close the stream early, and the queue may move on to
the next turn while this one is ending, and the client had no way to tell the
two apart: it took the end of its own stream for the end of the turn, showed
the turn as done, and never followed the turn that was still running. The
answer to "is a turn still live?" was available the whole time — the runtime's
active-turn read — and the command set already used it for a `session.start`
it never saw. A local send simply never asked.

A send now reconciles the session it landed on once its own stream is done:
the same owner the event path uses asks the runtime and re-attaches to
whatever is still running. The session to reconcile is the one the send landed
on, not the one the command set was built for — a first message creates the
session it is sent to and the record moves onto it — so the application
resolves it from the completion.

A queued submission is left alone. It joined somebody else's turn and never
had a stream of its own to end, and the turn it joined is somebody else's
stream, which the queue's own events speak for.

The runtime's terminal frame is not the shortcut it looks like. It says that
*this* turn is over, and it says nothing about the next one: measured against
the real runtime, a send whose turn ended while the queue moved on still
carried its own `session_status:finished` frame, and the runtime reported the
*following* turn as live. So the frame settles this turn and nothing else, and
the client's question — is anything still running on this session — is only
ever answered by the runtime's read.

The four cases that pin this are `packages/webui/test/unit/conversation-turn-commands.test.ts`
(the send hands over the session it landed on; a first message hands over the
session it created rather than the home slot; a queued submission hands over
nothing) and `packages/webui/test/unit/webui-application.test.ts` (the whole
shape end to end: the send's own stream ends, the runtime says a turn is
still running, and the client attaches to it). The three that assert the
reconciliation fail on the trunk — the owner is never asked and the runtime's
read is never made — and the queued one holds on the trunk and holds here.
@modacker
modacker merged commit 491569c into webui Oct 24, 2026
8 checks passed
@modacker
modacker deleted the fix/webui-silent-early-exit branch October 24, 2026 05:48
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.

1 participant