🐛 fix(web): 修复 runtime events 内存无界增长 - #109
Conversation
- hydrateRuntimeEvents 补 trimRuntimeEvents:回放路径与流式 append 路径 同受 MAX_EVENTS_PER_THREAD(2000) 约束,重开超长线程不再全量驻留内存 - 新增 removeRuntimeEvents:移入回收站/永久删除时清理对应线程条目, RuntimeEventState Record 不再只增不减(LeftSidebar 与归档设置两条删除路径接入) Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering>
LRU capacity=8 已有界,但被删线程的完整消息数组会驻留到被挤出; 删除路径补 invalidate 即时释放(缓存本身有 invalidate 入口,只是没接)。 Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering>
与单线程删除路径(trashThread/removeThreadInputState)对齐:项目数据删除时 其会话进回收站,renderer 侧同步 removeRuntimeEvents + threadMessagesCache.invalidate, 避免 Record 只增不减的同类泄漏;keepHistory 模式会话仍在用,不清理。 Co-Authored-By: Claude <noreply@anthropic.com>
CavinHuang
left a comment
There was a problem hiding this comment.
Review 结论
核心修复正确,方向无异议:
- ✅
hydrateRuntimeEvents补 trim:与 append 路径同上限同规则(先丢头部 delta、保结构事件与 user 锚点),重开超长线程不再全量驻留 - ✅
removeRuntimeEvents纯函数语义干净:条目不存在返回原引用,幂等,且有测试锁定 - ✅ 删除路径覆盖完整:
LeftSidebar.trashThread+ArchiveSettings的移入回收站/永久删除/清空回收站三条路径全部接入;恢复路径依赖 hydrate 重建,不丢数据 - ✅ 验证:runtime-event-state 20 pass、projection 50 pass、typecheck 通过、CI 绿
已修复一处同类缺口(b5867ee63)
deleteWorkspace('deleteLumeData') 模式下会话随项目数据进回收站,但 renderer 侧未清理 runtime events / messages cache——与上述三条删除路径不一致,属于本 PR 要修的「Record 只增不减」同类泄漏(一个工作区可能有几十个线程,每个最多 2000 events + 完整消息数组)。已在 LeftSidebar.remove() 的 deleteLumeData 分支对工作区线程统一 removeRuntimeEvents + invalidate;keepHistory 会话转为普通会话仍在使用,不清理。
非阻塞备注
- 删除运行中线程的迟到事件:
useGlobalAgentListeners无差别 append,run 进行中删线程会重建条目——有 2000 上限兜底且 run 结束即停止增长,可接受;如要彻底干净,可在总线订阅处按已删线程过滤(follow-up 即可)。 sameRuntimeEvents逐事件 JSON.stringify 在 2000 条时开销可观——本 PR 的 trim 反而让它更便宜(比较 2000 vs 全量),顺带受益。
结论:修复后 Approve 合并。
| const events = mergeHydratedRuntimeEvents(result.events, current?.events ?? []) | ||
| // 与 append 路径同上限:sidecar 回放不封顶,hydrate 不 trim 会让重开的超长线程 | ||
| // 全量驻留内存(且直到下一条 append 前都无界)。trim 规则同 append:先丢头部 delta。 | ||
| const events = trimRuntimeEvents(mergeHydratedRuntimeEvents(result.events, current?.events ?? [])) |
There was a problem hiding this comment.
🔴 数据丢失风险:hydrate 新增的 trim 会永久丢失头部 turn 的助手正文。
trimRuntimeEvents 的安全前提是"被丢的头部 delta 对应 turn 已有 assistant.final 重建文本"(见 230-233 行注释),但该前提只在 live 路径成立——sidecar 回放投影(apps/sidecar/src/services/agent-runtime/runner/run-item-events.ts:164-200)只产出 assistant.delta / assistant.thinking_delta,从不产出 assistant.final(在整个 apps/sidecar/src 里 grep assistant.final 为空;final 只由 web 侧 live adapter 合成)。而 web 投影中无 final 的助手文本完全靠 delta 累积(runtime-event-message-projection.ts:297-309,text += event.delta)。
后果:超过 2000 条回放事件的长线程重开(或 MESSAGE_APPENDED 每条消息全量回灌,useGlobalAgentListeners.ts:331)→ 头部 delta 被丢弃 → 这些 turn 的助手回复/思考渲染为空。PR 前 hydrate 不封顶、重开可见全量历史,这是回归。且回放按"每内容块一条 delta"产出、mergeHydratedRuntimeEvents 又不做同流合并,回放事件计数比 live 合并后更膨胀,上限比 live 更容易触发。
| const events = mergeHydratedRuntimeEvents(result.events, current?.events ?? []) | ||
| // 与 append 路径同上限:sidecar 回放不封顶,hydrate 不 trim 会让重开的超长线程 | ||
| // 全量驻留内存(且直到下一条 append 前都无界)。trim 规则同 append:先丢头部 delta。 | ||
| const events = trimRuntimeEvents(mergeHydratedRuntimeEvents(result.events, current?.events ?? [])) |
There was a problem hiding this comment.
🔵 follow-up 标记:渲染端 trim 只封内存,不封 IPC 载荷。
GET_THREAD_RUNTIME_EVENTS(apps/sidecar/src/rpc/agent-handlers.ts:780,listThreadRuntimeEvents 无 limit 参数)在每次 MESSAGE_APPENDED 时仍把整个持久化日志全量序列化、跨 IPC 传输、解析、merge,再被 trim 丢掉大部分。commit message 自己点名"sidecar 回放不封顶",但只修了渲染端一半——服务器侧(给 listThreadRuntimeEvents 加 limit/seq 下限,或 append 时不再全量回拉)建议留 follow-up 记录,避免一年后没人把长线程的 IPC 延迟归因到这。
| @@ -362,6 +366,8 @@ export function LeftSidebar({ forceCollapsed = false }: { forceCollapsed?: boole | |||
| try { | |||
| await sidecarCall(AGENT_IPC_CHANNELS.TRASH_THREAD, { threadId: thread.id }) | |||
| removeThreadFromNavigation(thread.id) | |||
There was a problem hiding this comment.
🟠 相邻的 archiveThread(:334-352,同文件、同一菜单)没有接入这两行清理——归档路径上 Record 仍只增不减。
归档是完成会话的主要收尾动作(回收站是给不要的线程用的):长会话中归档 N 个线程,每个至多 2000 条事件对象 + threadMessagesCache 条目常驻内存直到重启。清掉并无副作用——归档线程恢复/重新打开时会走 AgentMessages.tsx:243 重新 hydrate,hydrateRuntimeEvents 也会从持久化事件重建 terminalStatus。建议本 PR 一并补上,或抽一个共享清理助手(见 ArchiveSettings 侧评论)。
| try { | ||
| await sidecarCall(AGENT_IPC_CHANNELS.TRASH_THREAD, { threadId: thread.id }) | ||
| removeThreadFromNavigation(thread.id) | ||
| setRuntimeEvents((prev) => removeRuntimeEvents(prev, thread.id)) |
There was a problem hiding this comment.
🟠 对仍在流式运行的线程,这条清理会被后续事件抵消。
sidecar 的 trashAgentThread(apps/sidecar/src/services/agent/agent-thread-manager.ts:799)只翻转 meta status,并不中止 run:(a) 已进 pendingRuntimeEventsRef 的批次会在下一次 rAF flush 时经 appendRuntimeEvents 重建刚删除的 prev[threadId];紧接着 (b) run 落盘触发 MESSAGE_APPENDED → GET_THREAD_RUNTIME_EVENTS(该 handler 直接读 session 目录,不按 trashed 状态过滤)再全量回灌至 2000 条。此时删除流程已执行完,再无任何路径清这个条目——本 PR 要修的内存驻留在"边跑边删"场景原样复发。
| await sidecarCall(AGENT_IPC_CHANNELS.TRASH_THREAD, { threadId: thread.id }) | ||
| removeThreadFromNavigation(thread.id) | ||
| setRuntimeEvents((prev) => removeRuntimeEvents(prev, thread.id)) | ||
| threadMessagesCache.invalidate(thread.id) |
There was a problem hiding this comment.
🟡 两个 TRASH_THREAD 路径的清理子集漂移:ArchiveSettings.handleTrash(:44-45)还会 removeDraft/removeHistory,这里只清 events+cache。
agentInputDraftAtom / agentInputHistoryAtom 是 atomWithStorage(键 agent-input-draft / agent-input-history,agent-atoms.ts:79+),持久化到 localStorage——从侧栏删除一个带未发送草稿的线程,其草稿与历史条目会跨重启永久孤儿化(线程已删,再无入口可触发清理)。同一用户动作因入口不同产生不同的持久化结果。
| setDraftState((prev) => removeDraft(prev, threadId)) | ||
| setHistoryState((prev) => removeHistory(prev, threadId)) | ||
| setRuntimeEvents((prev) => removeRuntimeEvents(prev, threadId)) | ||
| threadMessagesCache.invalidate(threadId) |
There was a problem hiding this comment.
🟡 建议把这套清理抽成共享 chokepoint(如 releaseThreadState(threadId)),而不是每个组件手拼一份子集。
本 PR 在 ArchiveSettings 组了 draft+history+events+cache 四件套,又在 LeftSidebar 手拼了不同的两件套,而线程移除实际有 6 个向量:归档、工作区 deleteLumeData 级联、项目删除级联、WelcomeView 删除重试线程、数据管理页清空回收站、sidecar 启动时 30 天自动清理——后四者完全没接清理(详见整体评论)。同类的 agentSubagentRunsAtom / agentSubagentWorkAtom 以及 useGlobalAgentListeners 的模块级 lifecycle Maps 在所有路径都无人清理。按调用点手拼的模式,每次都要重新发现全部向量,本 PR 相邻两个 handler 的不一致就是例证。
Code Review — 其余发现(diff 外文件,无法行内评论)🟠 服务端发起的线程移除从不触发渲染端状态释放
后果:这些线程从 UI 消失后, 🟡 第二个"清空回收站"入口零清理
🟡 条目在线程仍挂载时被移除会卡在降级投影(PLAUSIBLE,链路较长)
已行内评论的发现(见各文件评论):hydrate trim 丢失无 final 回放的助手正文(最高危)、流式中删除被后续事件抵消、archiveThread 未接清理、两 trash 路径清理子集漂移、建议抽共享清理 chokepoint、IPC 载荷未封顶 follow-up。 总体:状态模块层的改动(cap 放在 |
回放按内容块/流事件逐条产出 delta(无 live 的边流边合并),且 sidecar 回放 没有 assistant.final 兜底重建,投影正文完全靠 delta 累积——直接 trim 会把 头部 turn 的 delta 丢掉、渲染为空泡。按 live 同规则(hasSameAssistantStreamOwner) 合并相邻 delta 后再计数 trim,两条路径上限语义一致;合并确定性保证重开幂等。 Co-Authored-By: Claude <noreply@anthropic.com>
- 新 hook 四合一释放:draft/history(localStorage 持久化)/runtimeEvents/ threadMessagesCache,消除各路径手拼子集的漂移 - LeftSidebar trashThread 补 draft/history(原侧栏路径漏清,草稿孤儿化跨重启残留) - LeftSidebar archiveThread 接入清理:归档线程只能恢复后再打开,hydrate 会重建 - DataManagementSettings 清空回收站对齐 ArchiveSettings 同语义(先列回收站线程再释放) - deleteLumeData 分支换用 hook(补 draft/history) Co-Authored-By: Claude <noreply@anthropic.com>
Review 响应:逐条核实与处置对内联评审的 9 条发现逐一核实了事实链(sidecar 全目录 grep、投影/回放源码、双源优先级),结果如下。 已修复(2c99abde9 / 9cac260)#1 hydrate trim 丢回放正文(确认,最严重)——核实成立:sidecar 不产 #3 archiveThread 不清理(确认)——归档是高频收尾动作,已接入统一清理;归档线程只能恢复后再打开,hydrate 重建,无副作用。 #6 数据管理页清空回收站绕过清理(确认)—— #7 清理子集漂移:侧栏 trash 漏 draft/history(确认)——草稿是 atomWithStorage 跨重启残留,确为持久化孤儿。连同 #5 的可扩展性诉求一并处理:抽 不修,留 follow-up(附理由)
验证
|
背景
排查 runtime events 无界增长时发现两处问题:
appendRuntimeEvent(s)有MAX_EVENTS_PER_THREAD = 2000的 trim,但hydrateRuntimeEvents(重开线程回放)不 trim,而 sidecar 侧listThreadRuntimeEvents返回全量事件不封顶 → 重开超长线程时全量事件一次性驻留内存,直到下一条新事件 append 触发 trim 前一直无界。RuntimeEventStateRecord 只增不减:移入回收站/永久删除线程时不清条目,renderer 进程生命周期内每线程事件持续驻留。改动
hydrateRuntimeEvents:merge 后补trimRuntimeEvents,与 append 路径同上限、同 trim 规则(先丢头部 delta,保结构事件与 user 锚点)removeRuntimeEvents(prev, threadId)纯函数(条目不存在时返回原引用)ArchiveSettings.removeThreadInputState(回收站/永久删除/清空回收站,与 draft/history 清理同点)LeftSidebar.trashThread(侧栏移入回收站)threadMessagesCache.invalidate(threadId):LRU capacity=8 已有界,但被删线程的完整消息数组会驻留到被挤出,删除时即时释放验证
bun test src/hooks/runtime-event-state.test.ts:新增 2 个测试(hydrate 上限约束、removeRuntimeEvents 语义),20 passruntime-event-message-projection.test.ts:70 passbun run test:unit全量:39 文件 0 failbun run typecheck:通过说明
Generated with Claude Code
via Happy