7.7 KiB
tags, create time
| tags | create time | |||
|---|---|---|---|---|
|
2026-06-09 22:30 |
Skill Search / Skill Learning 溢出 Bug 修复报告
概述
审计发现 EXPERIMENTAL_SKILL_SEARCH 和 SKILL_LEARNING 子系统存在 5 个无界模块级状态,与 autonomy OOM 属于同类 bug。修复方案采用 FIFO/LRU 淘汰 + 运行时双层门控,默认关闭实验特性直到运维人员显式启用。
正文
问题背景
- Flow id:
recurring-bug-skill-overflow(与recurring-bug-loop-oom为姊妹审计) - 分支:
fix/loop-scheduled-autonomy-oom(已合并到 OOM PR,使用相同的 audit-and-cap 模式) - 触发: 合并 autonomy OOM 修复后,发现相邻的
EXPERIMENTAL_SKILL_SEARCH和SKILL_LEARNING子系统存在同类无界模块级状态。用户明确要求进行肯定也有同类溢出审计。
问题分析
autonomy OOM 来自无界模块级状态(运行记录、调度队列、心跳时间戳)随进程生命周期不断增长。skill search + skill learning 子系统在 5 个模块级 Map/Set 上表现出相同类型的 bug,其中只有一个在 scripts/defines.ts 中有文档记录("projectContext cache 无淘汰机制(非 GB 级主因)")。
这些 bug 之前是潜伏的,原因如下:
EXPERIMENTAL_SKILL_SEARCH/SKILL_LEARNING在DEFAULT_BUILD_FEATURES中默认启用,但测试通过是因为它们只走短路径- 这些无界缓存不是按每次工具调用增长,而是按不同查询 / 不同 cwd / 不同 skill 名称 / 不同 gap 信号 / 不同 promotion 增长,相对于会话长度是亚线性的,但永远单调递增
- 长时间运行的守护进程式进程(KAIROS 会话、多日 worktree)会观察到增长
模块级状态审计
| 文件:行 | 符号 | 修复前上限 | 修复前淘汰 |
|---|---|---|---|
intentNormalize.ts:52 |
cache: Map<query, keywords> |
无 | 仅 clearIntentNormalizeCache() 测试用 |
prefetch.ts:17 |
discoveredThisSession: Set<skillName> |
无 | 无 |
prefetch.ts:18 |
recordedGapSignals: Set<gapKey> |
无 | 无 |
projectContext.ts:48 |
contextCache: Map<cwd, ProjectContext> |
无 | 仅 resetProjectContextCacheForTest() 测试用 |
promotion.ts:26 |
sessionPromotedIds: Set<instinctId> |
无 | 仅 resetPromotionBookkeeping() 测试用 |
runtimeObserver.ts:61 |
lastProcessedMessageIds: Set<msgKey> |
MAX 1000 | FIFO trim 已有上限 |
toolEventObserver.ts:50 |
emittedTurns: Map<sid, Set<turn>> |
MAP_MAX 50, SET_MAX 100 | LRU prune 已有上限 |
observerBackend.ts:21 |
registry: Map<name, Backend> |
固定 N | 不适用——注册表模式,有限 |
Warning
8 个模块级可变状态中有 5 个无界。所有 5 个都在本次 PR 中修复。
严重性分析
每个条目的成本很小(key 字符串 + 小对象),所以正常工作站上几天内 OOM 不太可能。但以下场景值得关注:
intentNormalize.cache:每个不同的中文查询 → Haiku 调用 → 缓存。浏览大型中文代码库或重放大量 transcript 的会话可以命中数千个不同查询;约 600 字节/条 x 10k = 约 6 MB。此外,每次缓存未命中都是一次 Haiku API 调用,默认启用意味着每个新会话在首次非 ASCII 查询时都要付费——这是意外成本。projectContext.contextCache:每个SkillLearningProjectContext携带 instinct + skill 列表。多 worktree 编排器(就是这个仓库!)会突破典型的"每会话 1 个 cwd"假设。prefetchSets:在多话会话中,数千个 skill 发现名称会累积。sessionPromotedIds:实际风险最小(单会话通常个位数 promotion),但长期运行的沙盒可能推高它;防御性上限成本很低。
修复方案
| 文件 | 变更 |
|---|---|
src/services/skillSearch/intentNormalize.ts |
setCachedQueryIntent() 辅助函数,CACHE_MAX_ENTRIES=200 / CACHE_TRIM_TO=150,命中时 LRU touch |
src/services/skillSearch/prefetch.ts |
addBoundedSessionEntry() 辅助函数,SESSION_TRACKING_MAX=1000 / TRIM_TO=750;discoveredThisSession 和 recordedGapSignals 通过它路由 |
src/services/skillLearning/projectContext.ts |
setProjectContextCache() 辅助函数,PROJECT_CONTEXT_CACHE_MAX=32 / TRIM_TO=24,命中时 LRU touch |
src/services/skillLearning/promotion.ts |
recordSessionPromoted() 辅助函数,SESSION_PROMOTED_IDS_MAX=256 / TRIM_TO=192 |
src/services/skillSearch/featureCheck.ts |
双层门控:构建标志必须开启 且 SKILL_SEARCH_ENABLED=1 环境变量必须设置。环境变量未设置时默认关闭 |
src/services/skillLearning/featureCheck.ts |
同样的双层模式(构建标志 + SKILL_LEARNING_ENABLED=1 或 FEATURE_SKILL_LEARNING=1) |
scripts/defines.ts |
注释标注,说明构建标志现在仅用于编译命令;运行时激活由运维驱动 |
修复用 FIFO/LRU 淘汰在合理大小(200-1000 条)上限制所有 5 个。没有数据损坏风险:达到上限时的行为是良性的(重新发出重复信号、重新 Haiku 查询、重新解析 cwd 上下文)。
为什么默认关闭(而不是从构建中移除)
除了无界缓存问题外,还有三个原因:
- 隐式成本:
intentNormalize在缓存未命中时调用 Haiku。默认开启意味着输入中文的每个会话都要付费一次 API 调用,即使运维人员从未要求 skill search。 - 磁盘副作用:
SKILL_LEARNING附加观察者,将观察结果持久化到~/.claude存储。存储量应该是 opt-in,而不是后台行为。 - 实验状态:标志名称就是
EXPERIMENTAL_*。默认启用实验子系统与命名约定矛盾。
[!info] 修复不是从
DEFAULT_BUILD_FEATURES中移除标志——这样做也会从构建中剥离/skill-search和/skill-learningslash 命令,让运维人员没有 UI 来 opt-in。相反,featureCheck.ts中的激活逻辑改为双层门控:
- 第 1 层(编译时):
feature('EXPERIMENTAL_SKILL_SEARCH')/feature('SKILL_LEARNING')必须开启。这些保留在DEFAULT_BUILD_FEATURES中,以便 slash 命令和观察者被编译进来。- 第 2 层(运行时):
SKILL_SEARCH_ENABLED=1/SKILL_LEARNING_ENABLED=1(或FEATURE_SKILL_LEARNING=1)环境变量必须设置。不设置的话,子系统存在但休眠——slash 命令存在,通过/skill-search或/skill-learning切换会翻转环境变量并激活热路径。最终结果:运维人员在 UI 中看到切换开关,但子系统关闭直到他们手动切换。
范围外(已提交 follow-up)
- CI 测试失败(
prefetch.test.ts、skillLearningSmoke.test.ts)出现在本分支 CI 中。两个测试都显式通过环境变量启用了特性,所以默认禁用不是原因。它们是实验代码路径中的既有功能问题,值得单独 flow 处理。 - 持久化层上限(观察文件、instinct 注册表):
observationStore.ts已有 30 天清除和 1MB 归档阈值;skillGapStore.ts使用有限状态生命周期。磁盘侧状态有适当限制;OOM 类问题严格在进程内状态。
验证
bun run typecheck # 0 errors
bun test src/services/skillSearch/__tests__/intentNormalize.test.ts
bun test src/services/skillSearch/__tests__/prefetch.extractQuery.test.ts
bun test src/services/skillLearning/__tests__/projectContext.test.ts
bun test src/services/skillLearning/__tests__/promotion.test.ts
bun run lint
bun run build
新上限是可观察行为:在持续负载下,Map/Set 大小在配置的最大值处趋于平稳,而不是单调增长。