{{ message }}
Repository navigation
Two live answers must not write into one store - #259
Merged
Merged
Conversation
Signed-off-by: Oakkles <lmh3162066424@gmail.com>
Signed-off-by: Oakkles <lmh3162066424@gmail.com>
Contributor
Contributor
WaylandYang
approved these changes
Sep 3, 2026
WaylandYang
left a comment
Contributor
There was a problem hiding this comment.
Verified end to end (see comment above): concurrent streams isolated, stop scoped to one conversation, reattach and follow-up paths intact, web build green.
This was referenced Sep 3, 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.

The symptom
Start a question in conversation A, click over to conversation B while the answer is still streaming, and ask again. Both SSE handlers then write into one module-level store:
A-2 B-0 A-3 B-1 A-4 …— and stays on screen until you navigate away and backfinish()pulls B's stop button while B is still streamingabortRefnow points at B, so A can no longer be stopped: it runs to completion on its own, unbudgeted model spend with no recourse (the same credit that An empty balance should not spend the retry budget #195 and A failed job has no way back into the queue #216 treat as worth protecting)Reproduced at runtime with zero patches to shipped code: the actual
liveAnswer.tsandapi.ts(streamChat/reattachChat, which shareconsumeChatStream) executed against a wire-identical SSE server, with the guard, send handlers, stop button and claim check copied verbatim fromChat.tsx. Server-side log confirms A was never aborted; a control run with no switching is clean.The root cause
liveAnsweris a singleton on a stated ground: 同时只会有一次进行中的回答. Nothing enforces that, and the rest of the file contradicts it on purpose — three places inChat.tsxestablish two concurrent generations as a normal, intended state:So the guard's own intent creates the state the single slot cannot hold: the second
startreplaces the store while the first stream's callbacks keep callingpatchLast— which patches the last turn of whatever is in the slot now.What changes
liveAnswerbecomes a table keyed by conversation, not a slot. Each stream gets a handle atbegin(); every read and write goes through the handle, so A's deltas can only ever land in A's entry.begin(kbId, conversationId, turns, abort)returns a handle (identify/patchLast/finish/setAbort); a not-yet-named conversation occupies an internal token untilidentifyremaps it —kbIddisambiguates two such windowsentry(kbId, conversationId)is the claim ("is the conversation I'm looking at live here");stop(kbId, conversationId)is the stop button: abort + finish for exactly the entry on screenbeginevicts finished entries — eviction only happens when somebody sends again, and an evicted conversation re-reads identical content from the databaseabortRefis gone entirely. It was the direct mechanism of the orphaned stream; abort now travels with its own entryliveHere?.turns ?? turns— what is on screen — instead of the stale component state, so the previous answer no longer vanishes from the display when you ask again (fixed in a separate commit)No UI or i18n changes; the interface looks exactly the same when only one answer streams.
Verification
Both checks run without a backend:
cd web && pnpm buildgreen (the CI web check, includestsc --noEmit)stop(B)aborts only B and the server log shows it; A is then independently stoppable (no orphan, no full-length run); finished entries remain claimable (An answer being written should still be there when you come back #171's display continuity) and are evicted by the nextbegin;identifyremaps the pending entry; the reattach path (refresh-mid-stream) builds its entry from history + snapshot and writes only its own; the control case behaves as before