fix(harness): drop an orphan reasoning-end instead of letting it kill the run - #6702
Merged
Conversation
… the run The AI SDK reducer throws on an end for a part it does not have open — `Received reasoning-end for missing reasoning part with ID "stream-2"` — and that throw tears down the Studio-side stream mid-run. Observed live: an agent that had cloned its repo and was two tool calls in had its run marked finished and its card advanced, while the pod kept working for minutes afterwards, oblivious. Two things upstream can separate an end from its start: - a restart drops the start. `pending` buffers every chunk until the turn's message id is known, and an abandoned attempt clears it wholesale along with the coalescer — but `UiChunkTranslator` keeps its `openStreamBlocks`, so the next `message_start` closes a block whose start went in the bin; - a `finish-step` lands between them. The reducer CLEARS its open parts on that boundary, so any end after it is an orphan by definition. The step-boundary call sites close open blocks first for exactly this reason — this is the backstop for the next call site that forgets. Rather than chase each path, the guard sits at the one place every chunk reaches the consumer and enforces the reducer's own rule: a start opens an id, an end closes it, `finish-step` clears them all, and an end with no open id is dropped. The part it referred to is already closed or was never opened, so dropping it costs nothing and keeps the run alive. Keyed on kind AND id, like the coalescer's merge check: ids are minted distinctly, so a reasoning-end landing on a text part's id means something upstream is confused and closing that part would corrupt it. Buffered chunks are guarded at the `startTurn` flush, not on the way into `pending`: an end whose start is still buffered is not an orphan, and `pending` is dropped wholesale on a restart, so what survives is only decidable at the emit that actually sends it.
decocms Bot
pushed a commit
that referenced
this pull request
Aug 28, 2026
PR: #6702 fix(harness): drop an orphan reasoning-end instead of letting it kill the run Bump type: patch - @decocms/harness-runner (packages/harness-runner/package.json): 0.9.0 -> 0.9.1 Deploy-Scope: both
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.
An agent cloned its repo, made two tool calls, and its card went to In Review reading "Completed — I'll start by cloning the repo." The agent was still running in its pod, and kept running for minutes after Studio had torn its stream down and moved on.
The AI SDK reducer throws on an end for a part it does not have open, and that throw kills the stream.
Two ways a start goes missing
pendingbuffers every chunk until the turn's message id is known, and an abandoned attempt clears it wholesale (pending.length = 0) along with the coalescer — butUiChunkTranslatorkeeps itsopenStreamBlocks, so the nextmessage_startcloses a block whose start went in the bin.finish-steplands between them. The reducer CLEARS its open parts on that boundary, so every end after it is an orphan by definition. The step-boundary call sites already close open blocks first for exactly this reason — this is the backstop for the next call site that forgets.The fix
Rather than chase each path, the guard sits at the one place every chunk reaches the consumer and enforces the reducer's own rule directly: a start opens an id, an end closes it,
finish-stepclears them all. An end with no open id is dropped — the part it referred to is already closed or was never opened, so dropping it costs nothing and keeps the run alive.Keyed on kind and id, like the coalescer's merge check: ids are minted distinctly, so a
reasoning-endlanding on atextpart's id means something upstream is confused, and closing that part on its behalf would corrupt it rather than just misorder it.Buffered chunks are guarded at the
startTurnflush, not on the way intopending— an end whose start is still buffered is not an orphan, andpendingis dropped wholesale on a restart, so what survives is only decidable at the emit that actually sends it.Testing
packages/harness-runner/orphan-end-guard.test.ts— 6 cases: the matched pair passes untouched, the bare end is dropped,finish-steporphans a still-open part, a double end is dropped, kinds are tracked independently, and non-lifecycle chunks (includingnulland a bare string) pass through.bun test packages/harness-runner/→ 78 pass / 0 fail.bun run --cwd=apps/api check,bun run lint(0 errors),bun run fmtclean.Known, not fixed here
The pump rethrows correctly, but the run still landed as
completedrather thanfailed, which is what let the card advance to In Review while the agent was mid-work. That mislabel is a separate defect — worth its own change, and it only bites when a crash happens, which this PR is meant to stop.Summary by cubic
Adds an orphan-end guard in
packages/harness-runnerso areasoning-endortext-endfor a part the AI SDK doesn't have open is dropped instead of throwing and killing the stream. Previously such a chunk tore down the run mid-work; now the run continues and the chunk is ignored.finish-step.finish-step, kind separation, and pass-through chunks.completedinstead offailed.Written for commit 1694299. Summary will update on new commits.