-
Notifications
You must be signed in to change notification settings - Fork 1
🐛 fix(web): 修复 runtime events 内存无界增长 #109
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
2b876dd
fc3cf31
b5867ee
9cac260
2c99abd
8dd923a
bec3ecc
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -74,7 +74,10 @@ export function hydrateRuntimeEvents( | |
| if (result.events.length === 0) { | ||
| return prev | ||
| } | ||
| const events = mergeHydratedRuntimeEvents(result.events, current?.events ?? []) | ||
| // 与 append 路径同上限:sidecar 回放不封顶,hydrate 不 trim 会让重开的超长线程 | ||
| // 全量驻留内存(且直到下一条 append 前都无界)。merge 阶段已按 live 规则合并相邻 | ||
| // 同流 delta(回放无 assistant.final,正文全靠 delta,先合并再 trim 才不会误伤)。 | ||
| const events = trimRuntimeEvents(mergeHydratedRuntimeEvents(result.events, current?.events ?? [])) | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 数据丢失风险:hydrate 新增的 trim 会永久丢失头部 turn 的助手正文。
后果:超过 2000 条回放事件的长线程重开(或
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 follow-up 标记:渲染端 trim 只封内存,不封 IPC 载荷。
|
||
| if (current && sameRuntimeEvents(current.events, events)) { | ||
| return prev | ||
| } | ||
|
|
@@ -91,6 +94,17 @@ export function hydrateRuntimeEvents( | |
| } | ||
| } | ||
|
|
||
| /** 删除线程的 runtime events 条目(回收站/永久删除时调用,防止 Record 只增不减)。 */ | ||
| export function removeRuntimeEvents( | ||
| prev: RuntimeEventState, | ||
| threadId: string, | ||
| ): RuntimeEventState { | ||
| if (!(threadId in prev)) return prev | ||
| const next = { ...prev } | ||
| delete next[threadId] | ||
| return next | ||
| } | ||
|
|
||
| function mergeHydratedRuntimeEvents( | ||
| persistedEvents: LumeRuntimeEvent[], | ||
| liveEvents: LumeRuntimeEvent[], | ||
|
|
@@ -103,7 +117,30 @@ function mergeHydratedRuntimeEvents( | |
| seenIds.add(event.id) | ||
| merged.push(event) | ||
| } | ||
| return sortRuntimeEvents(merged) | ||
| // 回放按内容块/流事件逐条产出 delta,不像 live 路径边流边合并,计数远更膨胀—— | ||
| // 直接 trim 会把头部 turn 的正文 delta 丢掉(回放没有 assistant.final 兜底重建, | ||
| // 投影 text 完全靠 delta 累积,丢了就是空泡)。先按 live 同规则合并相邻同流 delta, | ||
| // 让上限语义两条路径一致。 | ||
| return mergeAdjacentStreamDeltas(sortRuntimeEvents(merged)) | ||
| } | ||
|
|
||
| function mergeAdjacentStreamDeltas(events: LumeRuntimeEvent[]): LumeRuntimeEvent[] { | ||
| const merged: LumeRuntimeEvent[] = [] | ||
| for (const event of events) { | ||
| const last = merged[merged.length - 1] | ||
| if ( | ||
| last !== undefined | ||
| && (last.type === 'assistant.delta' || last.type === 'assistant.thinking_delta') | ||
| && (event.type === 'assistant.delta' || event.type === 'assistant.thinking_delta') | ||
| && last.type === event.type | ||
| && hasSameAssistantStreamOwner(last, event) | ||
| ) { | ||
| merged[merged.length - 1] = { ...last, delta: last.delta + event.delta } | ||
| continue | ||
| } | ||
| merged.push(event) | ||
| } | ||
| return merged | ||
| } | ||
|
|
||
| function compareRuntimeEvents(a: LumeRuntimeEvent, b: LumeRuntimeEvent): number { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| import * as React from 'react' | ||
| import { useSetAtom } from 'jotai' | ||
| import { agentInputDraftAtom, agentInputHistoryAtom, agentRuntimeEventsAtom } from '@/atoms' | ||
| import { removeRuntimeEvents } from '@/hooks/runtime-event-state' | ||
| import { threadMessagesCache } from '@/components/agent/thread-messages-cache' | ||
| import { removeDraft, removeHistory } from '@/lib/agent-input-draft-state' | ||
|
|
||
| /** | ||
| * 线程移入回收站/永久删除/随项目数据删除时的渲染端状态统一释放。 | ||
| * | ||
| * 所有移除路径必须走这里而不是手拼子集:draft/history 是 atomWithStorage | ||
| * (localStorage,跨重启残留),runtimeEvents/messagesCache 是会话期内存驻留, | ||
| * 漏清任何一项都会孤儿化。恢复路径无需对称操作——重开线程时 hydrate 会重建。 | ||
| */ | ||
| export function useReleaseThreadState() { | ||
| const setDraftState = useSetAtom(agentInputDraftAtom) | ||
| const setHistoryState = useSetAtom(agentInputHistoryAtom) | ||
| const setRuntimeEvents = useSetAtom(agentRuntimeEventsAtom) | ||
| return React.useCallback( | ||
| (threadId: string) => { | ||
| setDraftState((prev) => removeDraft(prev, threadId)) | ||
| setHistoryState((prev) => removeHistory(prev, threadId)) | ||
| setRuntimeEvents((prev) => removeRuntimeEvents(prev, threadId)) | ||
| threadMessagesCache.invalidate(threadId) | ||
| }, | ||
| [setDraftState, setHistoryState, setRuntimeEvents], | ||
| ) | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟠 相邻的
archiveThread(:334-352,同文件、同一菜单)没有接入这两行清理——归档路径上 Record 仍只增不减。归档是完成会话的主要收尾动作(回收站是给不要的线程用的):长会话中归档 N 个线程,每个至多 2000 条事件对象 +
threadMessagesCache条目常驻内存直到重启。清掉并无副作用——归档线程恢复/重新打开时会走AgentMessages.tsx:243重新 hydrate,hydrateRuntimeEvents也会从持久化事件重建terminalStatus。建议本 PR 一并补上,或抽一个共享清理助手(见 ArchiveSettings 侧评论)。