Files

110 lines
7.7 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
tags:
- OOM
- 内存溢出
- 缓存治理
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"假设。
- **`prefetch` Sets**:在多话会话中,数千个 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 上下文)。
### 为什么默认关闭(而不是从构建中移除)
除了无界缓存问题外,还有三个原因:
1. **隐式成本**:`intentNormalize` 在缓存未命中时调用 Haiku。默认开启意味着输入中文的每个会话都要付费一次 API 调用,即使运维人员从未要求 skill search。
2. **磁盘副作用**:`SKILL_LEARNING` 附加观察者,将观察结果持久化到 `~/.claude` 存储。存储量应该是 opt-in,而不是后台行为。
3. **实验状态**:标志名称就是 `EXPERIMENTAL_*`。默认启用实验子系统与命名约定矛盾。
> [!info]
> 修复**不是**从 `DEFAULT_BUILD_FEATURES` 中移除标志——这样做也会从构建中剥离 `/skill-search` 和 `/skill-learning` slash 命令,让运维人员没有 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 类问题严格在进程内状态。
### 验证
```bash
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 大小在配置的最大值处趋于平稳,而不是单调增长。
## 关联笔记
- [[sur-loop-scheduled-oom]]