Skip to content

🐛 fix(web): 修复 runtime events 内存无界增长 - #109

Merged
CavinHuang merged 7 commits into
mainfrom
fix/runtime-events-unbounded
Aug 19, 2026
Merged

🐛 fix(web): 修复 runtime events 内存无界增长#109
CavinHuang merged 7 commits into
mainfrom
fix/runtime-events-unbounded

Conversation

@CavinHuang

@CavinHuang CavinHuang commented Aug 18, 2026

Copy link
Copy Markdown
Owner

背景

排查 runtime events 无界增长时发现两处问题:

  1. hydrate 路径绕过 2000 上限appendRuntimeEvent(s)MAX_EVENTS_PER_THREAD = 2000 的 trim,但 hydrateRuntimeEvents(重开线程回放)不 trim,而 sidecar 侧 listThreadRuntimeEvents 返回全量事件不封顶 → 重开超长线程时全量事件一次性驻留内存,直到下一条新事件 append 触发 trim 前一直无界。
  2. RuntimeEventState Record 只增不减:移入回收站/永久删除线程时不清条目,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 pass
  • runtime-event-message-projection.test.ts:70 pass
  • bun run test:unit 全量:39 文件 0 fail
  • bun run typecheck:通过

说明

  • 回收站恢复后重新打开线程会走 hydrate 重建条目,清理不丢数据
  • jsonl 磁盘 append-only 无回收是"持久化即承诺"设计的一部分,不在本 PR 范围

Generated with Claude Code
via Happy

TaTaLiao and others added 3 commits August 18, 2026 23:11
- 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 CavinHuang left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 + invalidatekeepHistory 会话转为普通会话仍在使用,不清理。

非阻塞备注

  1. 删除运行中线程的迟到事件useGlobalAgentListeners 无差别 append,run 进行中删线程会重建条目——有 2000 上限兜底且 run 结束即停止增长,可接受;如要彻底干净,可在总线订阅处按已删线程过滤(follow-up 即可)。
  2. 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 ?? []))

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 数据丢失风险: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 ?? []))

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 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)

Copy link
Copy Markdown
Owner Author

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 侧评论)。

try {
await sidecarCall(AGENT_IPC_CHANNELS.TRASH_THREAD, { threadId: thread.id })
removeThreadFromNavigation(thread.id)
setRuntimeEvents((prev) => removeRuntimeEvents(prev, thread.id))

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 对仍在流式运行的线程,这条清理会被后续事件抵消。

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_APPENDEDGET_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)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 两个 TRASH_THREAD 路径的清理子集漂移:ArchiveSettings.handleTrash(:44-45)还会 removeDraft/removeHistory,这里只清 events+cache。

agentInputDraftAtom / agentInputHistoryAtomatomWithStorage(键 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)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 建议把这套清理抽成共享 chokepoint(如 releaseThreadState(threadId)),而不是每个组件手拼一份子集。

本 PR 在 ArchiveSettings 组了 draft+history+events+cache 四件套,又在 LeftSidebar 手拼了不同的两件套,而线程移除实际有 6 个向量:归档、工作区 deleteLumeData 级联、项目删除级联、WelcomeView 删除重试线程、数据管理页清空回收站、sidecar 启动时 30 天自动清理——后四者完全没接清理(详见整体评论)。同类的 agentSubagentRunsAtom / agentSubagentWorkAtom 以及 useGlobalAgentListeners 的模块级 lifecycle Maps 在所有路径都无人清理。按调用点手拼的模式,每次都要重新发现全部向量,本 PR 相邻两个 handler 的不一致就是例证。

@CavinHuang

Copy link
Copy Markdown
Owner Author

Code Review — 其余发现(diff 外文件,无法行内评论)

🟠 服务端发起的线程移除从不触发渲染端状态释放

THREAD_LIST_CHANGED 处理器(apps/web/src/hooks/useGlobalAgentListeners.ts:279-287)只做 setThreads 刷新列表,从不释放消失线程的 per-thread 状态。以下向量全部绕过本 PR 的清理:

  • 项目删除级联:deleteLumeData 模式在 sidecar 把项目下全部线程 trash(agent-project-lifecycle-service.ts:215 trashAgentThreads),渲染端只收到 THREAD_LIST_CHANGED;
  • 工作区移除:LeftSidebar.deleteWorkspace(:425-440)在 deleteLumeData 模式下同样批量 trash N 个线程,只重拉 LIST_THREADS;
  • sidecar 启动自动清理:cleanupExpiredTrash(apps/sidecar/src/index.ts:430,30 天过期回收站)直接 deleteAgentThread,无任何渲染端广播;
  • WelcomeView 删除重试线程(:408、:515 对 aborted/failed 提交尝试 DELETE_THREAD)同样无清理。

后果:这些线程从 UI 消失后,agentRuntimeEventsAtom 中每线程 ≤2000 条事件与 threadMessagesCache 条目滞留整个会话期,且不再有任何 UI 动作可以触发清理。THREAD_LIST_CHANGED(或一个专门的 thread-removed 事件)是天然的 chokepoint:diff 一下前后线程列表,消失的即释放。

🟡 第二个"清空回收站"入口零清理

apps/web/src/components/settings/DataManagementSettings.tsx:114 handleEmptyTrash 直接调 emptyTrash() IPC,不做 removeRuntimeEvents/threadMessagesCache.invalidate——与 ArchiveSettings.handleEmptyTrash 同一用户动作、两个入口、两种清理行为。

🟡 条目在线程仍挂载时被移除会卡在降级投影(PLAUSIBLE,链路较长)

AgentMessages.tsx:240-251 的 refetch 守卫:loadedRuntimeEventThreadsRef 已含该 threadId 时,runtimeEvents 变空也不会重新拉取。归档父线程会级联归档子线程(sidecar agent-thread-manager.ts:788),而 removeThreadFromNavigation 只移除父线程 id 的 tab——子线程只读 tab 可保持挂载;此后从设置里把该子线程移入回收站 → 事件被清 → 投影回退 visibleThreadMessages(工具调用/计划/todo 视图消失、消息 id 换源),且无重拉修复。建议归档/trash 级联时按 parentThreadId 联动移除 tab,或在守卫上放宽"条目被外部删除"的情形。


已行内评论的发现(见各文件评论):hydrate trim 丢失无 final 回放的助手正文(最高危)、流式中删除被后续事件抵消、archiveThread 未接清理、两 trash 路径清理子集漂移、建议抽共享清理 chokepoint、IPC 载荷未封顶 follow-up。

总体:状态模块层的改动(cap 放在 hydrateRuntimeEventsremoveRuntimeEvents 的位置)层级正确;主要风险是 trim 的"final 重建文本"前提在回放路径不成立(数据丢失回归),以及清理只接了 6 个移除向量中的 2 个。

TaTaLiao and others added 2 commits August 19, 2026 09:23
回放按内容块/流事件逐条产出 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>
@CavinHuang

Copy link
Copy Markdown
Owner Author

Review 响应:逐条核实与处置

对内联评审的 9 条发现逐一核实了事实链(sidecar 全目录 grep、投影/回放源码、双源优先级),结果如下。

已修复(2c99abde9 / 9cac260

#1 hydrate trim 丢回放正文(确认,最严重)——核实成立:sidecar 不产 assistant.final,投影正文完全靠 delta 累积(runtime-event-message-projection.ts:297-309),且 AgentMessages.tsx:80-88 的双源逻辑是 events 非空就不回退消息源,被 trim 的头部 turn 确实渲染为空。修法:mergeHydratedRuntimeEvents 排序后按 live 同规则(hasSameAssistantStreamOwner)合并相邻同流 delta 再 trim——回放每内容块/每流事件一条 delta 的膨胀计数被压缩回与 live 一致,绝大多数线程不再触上限;合并确定性保证重开幂等(测试锁定:2500 条同流 chunk 合并后正文完整 + 二次 hydrate 引用稳定)。超过 2000 合并事件的极端线程仍走既有 tradeoff(MAX_EVENTS_PER_THREAD 注释已声明的"彻底解耦待重构"),非本 PR 引入。

#3 archiveThread 不清理(确认)——归档是高频收尾动作,已接入统一清理;归档线程只能恢复后再打开,hydrate 重建,无副作用。

#6 数据管理页清空回收站绕过清理(确认)——DataManagementSettings.handleEmptyTrashLIST_TRASHED_THREADS 再逐线程释放,与 ArchiveSettings 同语义。

#7 清理子集漂移:侧栏 trash 漏 draft/history(确认)——草稿是 atomWithStorage 跨重启残留,确为持久化孤儿。连同 #5 的可扩展性诉求一并处理:抽 useReleaseThreadState() hook 四合一释放(draft/history/runtimeEvents/messagesCache),四个移除路径(侧栏 trash、归档、ArchiveSettings 三动作、工作区 deleteLumeData)全部走同一 chokepoint,后续新增移除向量只需接一处。未顺带清理 subagentRuns/lifecycle Maps——不在本 PR 修复的问题域内,见 follow-up。

不修,留 follow-up(附理由)

验证

  • runtime-event-state 21 pass(+1 回归测试)、projection 50 pass、targeted 87 pass
  • bun test apps/web/src 全量 1030 pass / 92 fail——失败全部位于本 PR 未触碰文件,main baseline 同跑 102 fail(平台/环境既有红),零新增

@CavinHuang
CavinHuang merged commit d9bdb40 into main Aug 19, 2026
6 checks passed
@CavinHuang
CavinHuang deleted the fix/runtime-events-unbounded branch August 19, 2026 03:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants