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

15 KiB
Raw Blame History

工程教训

这个仓库踩过的坑,以及它们共同的形态。写在这里是因为它们比实现细节更耐用 —— 代码会改,缺陷形态会重复出现。

来源:这些条目原本散落在各份执行计划(memory-*-plan.md、 toolcall-parallel-execution-plan.md 等)里。计划本身已随实施完结而删除, 但教训被提出来了 —— 那是它们唯一还有价值的部分。

建立于 2026-10-31。


一、判据类(最贵的教训,占了一半)

1.1 「结构判据」不能替代「质量判据」

症状:16 条判据、6 个变异全部通过,而功能完全不可用。

当时的场景:记忆检索改造。判据验的是「字段非空」「条数正确」「不 panic」, 而实际检索结果是:

查询 "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):

// 事实清单(来自探测,见 docs/zh/interrupt-subsystem-split-plan.md §1.5)。
// 这里只断言"这些字段在 scheduler 上存在",作为改写时的锚点;

问题有三层:

  1. 它引用的是一份"未实施计划" —— 被引用方自己写着 「状态:计划(未实施)」。判据锚在一个不存在的事实上。
  2. 它没测任何契约。实际断言只有"入队后队列里 1 条", 而那是中断功能最基本的正确性 —— 跟它声称的"反向依赖现状快照"毫无关系。
  3. 它把"未做的工作"变成了通过的判据。改任何东西都不会让它红, 除非入队函数彻底坏掉;而那时别的判据早就红了。

⇒ 出现这种形态时,正确处置是删掉它(或改写成真判据), 然后把这个待办登记进 future-plan.md。 不存在"用一条永远绿的判据占位待办"这种合法形态。

判据要问:这条判据的依据是代码行为,还是一份可能会变的文档? 如果是后者,它保护的不是系统,是文字。

5.3 测试文件与业务代码分离

现状(2026-11-04 实测):

全仓测试文件            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)。

后果:

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)

所以写判据、写文档、写代码时,都要问一句:我现在拿到的这个信号, 在什么情况下会是假的?