Files
HomeAgent/docs/zh/engineering-lessons.md
JianFeeeee 7cef7df6f2 test: 判据改为面向接口输入输出 + 文档立规 + CI 补缺口
本提交是三件事的第一批,后续按计划分步推进。

## 一、判据改造(两条实证)

### 1. turn_hint 判据原是自证循环

原测试在测试里**手动重放**了真实注入逻辑:

    f.Msgs = a.buildMessages(...)
    hint := stageHint(f.StageCtx, "turn_hint")
    f.Msgs = append(f.Msgs, Message{Role:"system", Content:"[Turn hints] "+hint})
    // 然后断言"最后一条是 system、内容含 [Turn hints]"

它断言的是**它自己刚 append 的那一行** —— 把 task.go:656-661 抄进测试
再验证抄得对不对。真实代码那段删掉,它照样绿。

改为:把 turn_hint 放进 StageCtx,跑**真实的 stepPrepare**
(newTaskFrame 起始就是 StepPrepare),检查它产出的 Msgs。

变异验证:删掉 task.go 里 `if hint := stageHint(...)` 那段 ⇒ 实测红。

### 2. webui 文件下发判据原是白盒

原测试直接调未导出的 `stageWebFile(src, isImage)`、断言 `webFilesDir`
包级变量 —— 那都是**实现方式**。

改为从**输出通道 handler** 走(`RegisterOutputChannel("webui", …)`,
agent 发图/发文件时走的就是它):

    输入:{payload: "<路径或URL>", type: "image"|"file"}
    输出:{status:"ok", url:"/files/<name>.<ext>", size:N}

4 条判据(新版 handler_files_contract_test.go):
  · 图片 → 可下载 URL(前缀/扩展名/size)
  · 远端 URL 原样透传(不下载)
  · 危险扩展名被中和(.html 保留会在 webui 域执行脚本)
  · 空 payload 被拒

变异验证:丢扩展名 ⇒ 红;远端也拷贝 ⇒ 红。

## 二、文档立规:engineering-lessons 新增第五节

原来只有「判据会骗人」,没有「判据本身形状错了」。新增 6 小节:

    5.1  判据必须把被测模块当黑箱(含"如果重写内部实现、接口不变,
         这条判据还会绿吗"这个可操作判据)
    5.2  禁止读源码做断言(附正确替代表)
    5.2.1 判据不得以文档为依据
    5.3  测试文件与业务代码分离(现状:521 文件 / 11.2 万行,
         agent/core 一个包 196 文件里 144 个是测试)
    5.4  CI 必须覆盖每个独立 module
    5.5  CI 与本地必须跑同一套门

§5.2.1 的直接触发:`interrupt_invariant_test.go` 里有一条判据写着
「事实清单(来自探测,见 docs/zh/interrupt-subsystem-split-plan.md §1.5)」
—— 被引用方自己标着「状态:计划(未实施)」,而那条判据的实际断言
只是"入队后队列里 1 条",与声称的"反向依赖现状快照"毫无关系。
该判据已删除(入队行为已由 scheduler_preempt_test 的黑箱断言覆盖)。

## 三、CI 补两个缺口

### gofmt 门

提交纪律要求 `gofmt -l` 干净,但三个 workflow 里 `gofmt` 出现 **0 次**
—— 纪律只靠人自觉。补上后未格式化的代码进不了主干。

只查本仓自管目录:SDK 是独立仓,格式由它自己的 CI 管。

### SDK 判据步骤

`third_party/hologramagentsdk/` 有自己独立的 go.mod:

    go list ./... | grep third_party   →  0 个

主仓 `go test ./...` **永远不包含** SDK 任何测试。实测 SDK 有 30 个判据
文件,主仓 CI 一次都没跑过。

补的步骤只跑主仓 git 跟踪的文件(CI 上只有 22 个 vendored 文件,
工作机上是完整独立仓)—— 实测 `git archive` 出 22 个文件后
`go test ./sdk/ ./meta/` 全绿。

## 四、SDK 仓测试迁移(独立仓,已单独提交 3a38ef5)

`sdk/{plugin,proxy,stress}_test.go` → `sdk/test/`,包名 `sdk` → `test`。

关键:原测试直接写 `&PluginSDK{regStage: X, name: "test"}` —— **私有字段**。
而 `New(name, sett, regTool, regStage, regAPI, regOutput)` 本来就把所有
registrar 作为参数,等价且是生产路径。共改 19 处,0 处剩余。

主仓同步更新了跟踪路径(这三个文件主仓也 vendor 了一份)。

## 验证

    go build ./...            PASS
    go vet ./...              PASS
    gofmt -l                  干净
    go test ./... -count=1    62 包全过,0 FAIL
    干净检出(worktree)       build + 改造后的判据全绿

## 后续(按计划分步)

  步骤 2  webui 29 个测试分类迁移(已实测出精确分类)
  步骤 3  agent/core 739 处白盒引用先分类再动
  步骤 4  SDK tools/hmapdev(package main,无法迁出,只加说明)
  步骤 5  CI 补 coverage + staticcheck
  步骤 6  文档双语(需定术语口径)
2026-11-04 18:11:06 +08:00

364 lines
15 KiB
Markdown
Raw 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.

# 工程教训
> 这个仓库踩过的坑,以及它们共同的形态。**写在这里是因为它们比实现细节更耐用** ——
> 代码会改,缺陷形态会重复出现。
>
> 来源:这些条目原本散落在各份执行计划(`memory-*-plan.md`、
> `toolcall-parallel-execution-plan.md` 等)里。计划本身已随实施完结而删除,
> 但教训被提出来了 —— 那是它们唯一还有价值的部分。
>
> 建立于 2026-10-31。
---
## 一、判据类(最贵的教训,占了一半)
### 1.1 「结构判据」不能替代「质量判据」
**症状**:16 条判据、6 个变异**全部通过**,而功能完全不可用。
**当时的场景**:记忆检索改造。判据验的是「字段非空」「条数正确」「不 panic」,
而实际检索结果是:
```text
查询 "doc 层召回是怎么设计的" → score=0.7834
查询 "hmap 插件安装验签" → score=0.7922
查询 "QQ 头像" → score=0.7601
```
**分数全挤在 0.75~0.79,区分度 ≈ 0,命中内容与查询毫无关系。**
⇒ 「全绿」证明的只是**我实现了我以为的东西**,不是它可用。
**判据要问**:这条判据在"功能完全失效"时会绿吗?会绿就不是质量判据。
### 1.2 判据测「接线之后」≠ 测「有没有接线」
这是本仓出现频率最高的缺陷形态(已攒够 5 例,见 `future-plan.md` §〇)。
两处实测:
| 判据 | 它做了什么 | 为什么永远绿 |
| --- | --- | --- |
| `security_toolgate_test.go` | 手动调 `a.SetSecurity(rt)` 再断言拦截 | **判据自己把缺失的接线补上了** |
| `retention_test.go` | 手写 `month_2026-01.tar.gz` 再断言被删 | 从没跑过 `compressMonthly` 的**真实产物**(那产出的是 `month_2026-27`,解析必失败) |
两者都全绿,两个功能在生产里都完全没生效。
**判据要问**:**这条判据在生产装配下也会绿吗?**
### 1.3 「验证通过」≠「架构正确」
**当时的场景**:`630d3f4` 的 16 三元组端到端验证全部通过。
但那次验证的是**拆分质量**(能不能把句子拆成主谓宾),
不是**正确的落库形态**(拆出来的三元组该以什么结构存)。
⇒ 验证通过之后才发现,整个蒸馏路线方向错了,要整体否决。
**判据要问**:我验证的,是不是我真正想保证的那件事?
### 1.4 fixture 照臆测字段编
本仓 T23 教训:判据用的 fixture 是
**手写臆测的字段名**,而真实结构体字段不同 ⇒ 判据测的是一个不存在的契约。
⇒ 判据的输入应当来自**真实产物**,不是人脑里的想象。
### 1.5 先改代码再写测试
原本记在记忆块流修复计划里(该计划已随实施完结删除):先改代码再补测试,
测试就只会证明「我改的是我以为的」。
⇒ 这个顺序本身会让判据失去独立价值。
---
## 二、接线类
### 2.1 机制 ≠ 接线
见 `future-plan.md` §〇。一句话总结:
**策略表存在、判定函数正确、日志里一条拒绝记录都没有** —— 看起来像「没人违规」。
判断方法:`codegraph_callers(符号名)`。0 个生产调用方就是没接线。
### 2.2 依赖图是唯一保证顺序的地方
`agent` 模块读了 `ks.Security` 却没在 `Requires()` 里声明 `FacSecurity`。
「恰好」能用(security 在第 1 层、agent 在第 4 层),但那是**偶然**:
一旦 security 加了自己的依赖被推后,就会静默拿到 nil。
⇒ 读了谁的字段,就必须声明对谁的依赖。**不要靠层号碰巧对。**
### 2.3 单数路径改了,复数路径漏了
`StopRTChild`(单数)注销入站登记,`StopRTChildren`(复数)漏了。
残留随 create/destroy 单调累积。
⇒ 写这类对称函数时,判据要**同时**钉两条路径。本仓已有判据专门做这件事。
---
## 三、权限与生命周期类
### 3.1 「接上了」有三种层次,容易停在第二层
| 层次 | 内容 |
| --- | --- |
| 1 | 机制存在(`CheckTool` / `ScanContent` 都写了) |
| 2 | 接到了执行链(`securityDecisionFor` 真的调它) |
| 3 | **模型正确** |
第三层的典型错误:把「子 agent 继承父的权限」当成「父为子声明策略」。
两者都"接上了",但权限模型完全不同 —— 前者意味着子等于父,后者意味着子更窄。
**判断标准**:**这条策略是谁的意图?** 权限的意图只能来自委派者(父),
不能来自被委派者(子),也不能来自内核默认(全局)。
### 3.2 以「调用方给的名字」为键的表,必须在生命周期结束时清理
两个实例:
- `rtClosedTranscripts[id]` —— 以模型给的名字为键,全仓 0 处删除。
结果:同名重建的 rtchild 读到上一通电话的原文
- `scopesByAgent[agentID]` 的 `longtimeProfile` —— 不释放则同名重建的 agent
继承上一代的约束
⇒ 键来自外部(模型、调用方)就不能假设它唯一。
### 3.3 提权口子:让调用方声明自己是谁
`Kind: "resident"` 由 agent 自己写 —— 它同时意味着「我是内部主体」
(豁免 HardPrivateTools)和「我不受收窄」。调用方说自己是内部就拿到豁免。
⇒ 类别必须由安全模块从**事实**(谁在调、有没有父)推出,不能由调用方声明。
---
## 四、流程类
### 4.1 长任务纪律
导出、回填等内存密集任务**绝不与测试并行**(同一会话里踩了三次 OOM)。
### 4.2 数字类断言要现场取,不要凭记忆
文档里写「17 模块 6 层」,实际是 **20 模块**。给读者的是错的坐标。
⇒ 数字必须附取数命令(如 `go run ./cmd/homed-depgraph -print`)。
### 4.3 计划文档不要进仓库
执行计划会在实施完结后变成**误导性读物**:它记录的是"当时打算做什么",
而读者会读成"现在是什么"。
本仓删掉了 9 份已完结的计划(`memory-*-plan.md` 等),保留:
- **设计文档**(`*-design.md`):为什么这样设计,有长期价值
- **教训**(本文):踩过什么坑
- **未做登记**(`future-plan.md`):还剩什么没做
- **操作手册**(`*-runbook.md`):怎么做
---
## 五、判据形态类(2026-11-04 立)
> 这一节回答一个具体问题:**判据应该写成什么形状?**
>
> 前面四节讲"判据会骗人"的各种形态。这一节讲**判据本身的形状错了**会怎样 ——
> 那比"判据写得不够严"更贵,因为形状错的判据会把**实现细节固化成契约**,
> 于是重构内部就必然破坏判据,作者只能二选一:不改代码,或者删判据。
### 5.1 判据必须把被测模块当黑箱
**模块化的目的就是"改内部实现不破坏外部接口"。判据如果不按这个层次写,
就等于取消了模块化的收益。**
白盒判据把内部状态变成契约。实测代价:
| 白盒判据 | 它固化了什么 | 后果 |
| --- | --- | --- |
| `if sf.Step != StepLLM` | 状态机停在哪一步 | 把 `StepLLM` 改名/拆步骤就假红,而行为完全正确 |
| `if f.ToolIdx == 3` | 循环推进方式 | 改成"整批提交"就假红,而结果一样 |
| `strings.Contains(src, "kstate.Mount(ks)")` | 源码字面量 | 改写成 `kstate.Mount(state)` 就假红(那是对的) |
| `os.ReadFile("../../events/bus.go")` 正则取常量 | 常量定义在哪个文件 | 把常量挪到 `types.go` 就假红 |
**判据要问**:**这条判据约束的是"对外承诺",还是"当前怎么实现的"?**
如果是后者,它就不该存在 —— 或者必须降级为"实现注释"(代码里的注释说明
为什么这样做),而不是判据。
#### 允许白盒的三种例外(其余一律黑箱)
1. **纯算法**:输入输出就是全部语义(裁剪算法、版本比较、分页计算)。
这类即使白盒也不固化"流程",因为它本来就只有"输入→输出"。
2. **并发/生命周期不变量**:无法从外部观测的时序约束,如"Dispose 期间
emit 不能读到已释放的指针"。这类**必须**用 `-race` + 并发压力,
而不是断言内部字段。
3. **熔断/重试状态机**:内部状态本身就是被需求规定的("连续 3 次崩溃
进入熔断")。这类要断言**状态转移的可观测效果**(还健康吗、
进重载队列了吗),不是断言状态字段的值。
#### 一个可操作的判据
写完一条判据,问自己:
> **"如果我把这个模块的内部实现完全重写、公开接口不变,
> 这条判据还会绿吗?"**
- 会绿 → 形状对了
- 会红 → 它钉的是实现,不是契约
### 5.2 禁止"读源码做断言"
`os.ReadFile("*.go")` + `strings.Contains` / `regexp` 是**反模式**,
即使动机正确(如"防止有人删掉装配的那一行")。
**为什么它看起来有吸引力**:当行为判据需要很重的 fixture 时,读源码很便宜。
**为什么它必须禁用**:
- 它测的是**文本**,不是**语义**。加空格、换变量名、等价重构都会让判断失效
(假红或假绿)
- 它把"文件布局"变成契约。移动代码到新文件就假红
- 它无法区分"代码存在"与"代码被执行"
**正确的替代**(按优先级):
| 你想保证的 | 错误做法 | 正确做法 |
| --- | --- | --- |
| 生产装配接了某根线 | 读源码找 `kstate.Mount(ks)` | 从零启动真实装配,断言**可观测结果**(如 `kstate.Get() != nil`) |
| 协议两端字段一致 | 正则提取两边的字符串字面量 | 起 handler,发真实事件,断言**收到的 JSON 字段** |
| 某段保护代码没被删 | 读源码找字符串 | 构造触发条件,断言**保护真的生效**(如请求被拒绝) |
| 某人没删掉某条判据 | 读测试文件找函数名 | CI 层守卫:`git diff --name-status` 检测删除 |
**唯一的例外**:CI 配置自检(如"排除表不许静默退化")。
那类检查的**对象就是配置文件本身**,读它是正当的 —— 因为它没有别的
可观测行为。但即便如此,也要在注释里写明"这是配置自检,不是行为判据"。
### 5.2.1 判据不得以文档为依据
**"测试引用文档"本身就是错的** —— 文档可以被改、被删、被改写,
而判据必须锚在**代码的当前行为**上。
实测反例(`internal/agent/core/interrupt_invariant_test.go:336`):
```go
// 事实清单(来自探测,见 docs/zh/interrupt-subsystem-split-plan.md §1.5)。
// 这里只断言"这些字段在 scheduler 上存在",作为改写时的锚点;
```
问题有三层:
1. **它引用的是一份"未实施计划"** —— 被引用方自己写着
「状态:计划(未实施)」。判据锚在一个**不存在的事实**上。
2. **它没测任何契约**。实际断言只有"入队后队列里 1 条",
而那是中断功能最基本的正确性 —— 跟它声称的"反向依赖现状快照"毫无关系。
3. **它把"未做的工作"变成了通过的判据**。改任何东西都不会让它红,
除非入队函数彻底坏掉;而那时别的判据早就红了。
⇒ 出现这种形态时,正确处置是**删掉它**(或改写成真判据),
然后把这个待办登记进 `future-plan.md`。
**不存在"用一条永远绿的判据占位待办"这种合法形态。**
**判据要问**:这条判据的依据是代码行为,还是一份可能会变的文档?
如果是后者,它保护的不是系统,是文字。
### 5.3 测试文件与业务代码分离
**现状(2026-11-04 实测)**:
```text
全仓测试文件 521 个
测试代码行数 112,068 行
internal/agent/core/ 196 个文件里 144 个是测试(73%)
internal/plugins/webui/ 29 个测试文件,全部 package webui(白盒)
全仓 package X_test 仅 3 个
```
问题不是"测试多",而是:
1. `ls internal/agent/core/` 看不出这个模块由什么组成
2. 100% 白盒 ⇒ 100% 的判据都能碰内部
3. 想改成黑箱必须先移动文件,而移动文件又会被源码扫描类判据挡住
**约定**(新代码必须遵守,存量逐步迁):
- **黑箱判据用 `package <pkg>_test`**,放 `*_contract_test.go`
- **白盒判据**(仅 §5.1 的三个例外)放 `*_internal_test.go`,
并在文件头注明"为什么必须白盒"
- 新增判据**默认黑箱**;要写白盒必须在文件头回答:
这条判据能用黑箱表达吗?为什么不能?
**这不是风格问题**:一个包里如果 90% 判据是白盒,这个包的模块边界
事实上不存在 —— 谁都不敢改内部实现。
### 5.4 CI 必须覆盖每个独立 module
**实测缺口(2026-11-04)**:主仓有 `third_party/hologramagentsdk/`,
它有自己独立的 `go.mod`(模块 `github.com/JianFeeeee/hologramagentsdk`)
**和独立的 `.git`**(remote: `github.com/JianFeeeee/hologramagentsdk`)。
后果:
```text
go list ./... | grep third_party → 0 个
```
主仓的 `go test ./...` **永远不包含** SDK 的任何测试。
- SDK 仓自己的 CI 会跑(已核实它的 `.github/workflows/ci.yml` 有
`go test ./...` 且单独处理了嵌套 module)
- 但**主仓 CI 只验证"能编译"**,不验证"SDK 的判据还绿"
⇒ 主仓改动可能依赖 SDK 的内部行为,而 SDK 判据红了主仓不知道。
**规则**:
1. 子目录里出现 `go.mod` ⇒ 它是独立 module,`go test ./...` 覆盖不到
2. 每个独立 module 必须在 CI 里有**自己的**测试步骤
3. 有独立 `.git` 的目录**不由主仓管理**,主仓只 vendor 必需文件;
`go.mod` 的 `replace` 指向它时,CI 必须确保这些文件存在且与
上游 HEAD 一致
### 5.5 CI 与本地必须跑同一套门
**实测缺口**:提交纪律要求 `gofmt -l` 干净,但三个 workflow 里
`gofmt` 出现 **0 次**。纪律只靠人遵守,CI 不兜底。
同理缺 `staticcheck` / `govulncheck` / coverage。
**规则**:写进提交纪律的每一条,都必须在 CI 里有对应步骤。
否则它不是纪律,是**建议**。
| 纪律条目 | CI 有没有兜底 |
| --- | --- |
| `go build ./...` | ✅ |
| `go vet ./...` | ✅ |
| `gofmt -l` 干净 | ❌ **必须补** |
| `go test -race` 全绿 | ✅ |
| 干净 worktree 能编译 | ❌ 靠人 |
---
## 附:这些教训的共同点
回头看,几乎每一条都是同一个形态的变体:
> **某种"看起来对了"的信号,与"真的对了"之间存在缺口。**
- 判据全绿 ≠ 功能可用
- 机制存在 ≠ 接线生效
- 层号碰巧对 ≠ 依赖声明正确
- 单数路径对 ≠ 复数路径也对
- 名字相同 ≠ 是同一个东西
- 计划写完 ≠ 事情做完
- **判据通过 ≠ 契约被保护**(它可能钉的是实现,见 §5.1)
- **`go test ./...` 绿 ≠ 全部测试跑过**(独立 module 不在里面,见 §5.4)
- **纪律写着 ≠ 有人执行**(CI 没兜底的纪律只是建议,见 §5.5)
**所以写判据、写文档、写代码时,都要问一句:我现在拿到的这个信号,
在什么情况下会是假的?**