[P1] Single-process streaming is fragile — global stream target, remount flicker, flush races #37
Labels
No labels
prio_critical
prio_low
type_bug
type_contact
type_issue
type_lead
type_question
type_story
type_task
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
lhumina_code/hero_shrimp#37
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Problem
The live-streaming layer relies on a single global
currentStreamingId, which is nulled the instant the dispatch RPC returns (so background-job tokens get dropped), plusmessagesis acreateSignal<Msg[]>so anysetMessagesremounts theLiveJobPanel(the "card keeps refreshing" flicker), plus a 60ms flush timer that can clobber the final reply. We've band-aided each of these repeatedly this session.Evidence
crates/hero_shrimp_web/ui/src/store.ts—currentStreamingId,jobStreamJobId,flushStreamBuffer,trackJobUntilDone(non-terminal must NOT setMessages, by comment).Proposed fix
Re-architect to a per-session typed event channel (cf. kimi-cli's "wire" SPMC protocol / Qwen-Code's typed event generator): events scoped by session, UI subscribes per conversation, message body updated via fine-grained store paths (not array replacement). Depends on the session_id fix.
Related: this is the root behind #29 and #31.
Filed from a comparative audit of Hero Shrimp vs Qwen-Code / kimi-cli / picoclaw (2026-05-23). Severity in title: P0=correctness/trust, P1=reliability/UX, P2=cleanup.
Implementation Spec for Issue #37
Objective
Migrate the chat thread's
messagesfrom a coarsecreateSignal<Msg[]>(whole-array, by-reference replacement) to a fine-grained SolidJScreateStore<Msg[]>, so per-token / per-tool body updates become path mutations (setMessages(i, "text", v)) that no longer replace the message object and therefore no longer remount theLiveJobPaneland other per-message subtrees keyed by reference. This eliminates the root-cause fragility on top of the band-aids already present.Current state (verified)
jobStreamJobId/jobNarration, non-terminal poll no longer callssetMessages, idempotent flush that SETs from a full buffer,clearStreamBufferbefore every finalization, partial session-scoping of SSE events).messagesis stillcreateSignal<Msg[]>(store.ts:253). EverysetMessages(m => m.map(...))allocates newMsgobjects, and<For each={messages()}>in ChatThread.tsx keys by object reference, so reactivity stays coarse — any per-turn update re-creates the object for the streaming message even when onlytextchanged. This is the remount-by-reference fragility the issue targets.jobNarrationsignal) and the throttled flush already AVOID touching the message object for the hot streaming path. The win here is structural correctness/robustness — it removes the entire class of "object replaced -> subtree remounted" failure for the in-thread streaming/tool/finalize paths.Requirements
messagesbecomes acreateStore<Msg[]>;setMessagesbecomes the store setter (path syntax +produce).Msg.messages()(proxy is non-callable) and read the array directly.<For each={messages}>so each row is identity-stable across body updates and only changed rows update.Files to Modify
crates/hero_shrimp_web/ui/src/store.ts— convert the signal to a store; rewrite all 13setMessagesmutation sites and 3 internalmessages()reads.crates/hero_shrimp_web/ui/src/components/ChatThread.tsx— changemessages()reads (3) and<For each={messages()}>.crates/hero_shrimp_web/ui/src/components/ConversationWork.tsx— changemessages().crates/hero_shrimp_web/ui/src/components/TopBar.tsx— changemessages().crates/hero_shrimp_web/ui/src/components/LibraryPage.tsx— changemessages().Implementation Plan
Step 1: Convert the declaration
Files:
store.tscreateStore,produce,reconcileimports fromsolid-js/store.export const [messages, setMessages] = createSignal<Msg[]>([]);withcreateStore<Msg[]>([]).Dependencies: none
Step 2: Rewrite internal
messages()reads (3 sites) in store.tsFiles:
store.tsloadConversationMessagesguardmessages().length->messages.length.sendMessagetitle capturemessages().filter(...)->messages.filter(...).Dependencies: Step 1
Step 3: Rewrite array-shape
setMessagessites (append/replace/clear)Files:
store.tssetMessages(reconcile(mapped, { key: "id" }))so reopening a conversation keeps row identity (no flash).setMessages(produce(m => { m.push(...) })).newConversationclear ->setMessages([]).Dependencies: Step 1
Step 4: Rewrite per-message
.mapmutation sites to path/produceFiles:
store.tsConvert each
setMessages(m => m.map(x => x.id === ID ? {...x, ...} : x))to aproducethat locates the row and sets narrow paths in place. Sites: restoreActivities; trackJobUntilDone terminal finalize; workspace divergence; job-attach; sync finalize; error path; stopStreaming cancel; flushStreamBuffer (hot path — preserve thex.jobIdskip + inline-think re-derivation); turn:end SSE finalize (preserve!x.streaming || x.jobIdskip and thefinalizedflag); tool:started/finished/refused SSE (mutate.activitiesin place).Dependencies: Step 1
Step 5: Migrate external consumers
Files:
ChatThread.tsx,ConversationWork.tsx,TopBar.tsx,LibraryPage.tsxmessages()->messages; autoscroll memo must touchmessages.lengthso it still fires on append;<For each={messages()}>-><For each={messages}>.Dependencies: Step 1 (Step 4 for the identity benefit)
Step 6: Build + type-check
Files:
crates/hero_shrimp_web/uiui/srcformessages(andsetMessages(to confirm zero call-style getters remain and every setter uses path/produce/reconcile.Dependencies: Steps 1-5
Acceptance Criteria
messagesis acreateStore<Msg[]>; nocreateSignal<Msg[]>remains.messages()call-style reads remain anywhere inui/src.setMessagessites compile against the store setter API; the streaming-body, turn:end, and tool-activity sites mutate fields in place viaproduce.reconcile(..., { key: "id" })and does not flash the thread.Risk / Scope notes
flushStreamBuffer(hot, must keep thex.jobIdskip + inline-think re-derivation),turn:end(must keep the streaming/jobId skip and thefinalized-> drainQueue flag),restoreActivities(reads neighbor rows).reconcile({ key: "id" })is required on the conversation-load site or reopen remounts every row (regression).currentStreamingId+jobStreamJobIdinto a per-session typed event channel (the issue's broader "wire/SPMC" vision) — that is a larger separate re-architecture. The createStore migration is the smallest high-value change that fixes the remount-by-reference root cause. The issue should NOT be auto-closed on this change alone.Notes
producelets the existing imperative.find/.pushlogic be kept almost verbatim inside a mutation draft, minimizing diff risk.Msgtype itself does not need to change.Test Results
Frontend-only refactor — verified via the project's TypeScript typecheck and the production Vite build.
TypeScript typecheck (
tsc --noEmit)integration): 14The 14 errors are pre-existing in unrelated files (ChatActivity, CostBadge, DiffViewer, HeroWelcome) and are unchanged by this work. The migration introduces zero new type errors.
Production build (
npm run build— vite build)Grep verification
messages()call-style reads remain inui/src.setMessages(m => ...map, spread) remain — every setter now usesproduce/reconcile/ a plain array.Pull request opened: #121 (base: integration)
This PR implements the createStore migration discussed in the spec above. Scope note: it fixes the remount-by-reference root cause but does not build the broader per-session typed event channel, so this issue should stay open (downgrade to P2) rather than auto-close.