Commit Graph

1045 Commits

Author SHA1 Message Date
07e4958e44 test(core): 修偶发红的 rtchild 用例,登记另一处未修的
## 背景

CI 的 `-race` 门两次变红(提交 `merge: pr31` 与 `style(webui)`),
都是 `TestRTChild_InterruptTruncatesOutput`:

    --- FAIL: TestRTChild_InterruptTruncatesOutput (5.00s)
        rtchild_r3_test.go:271: 等待超时:开始输出

本地 `-race` 单测 6/6、整包连跑 4 轮全过,复现不出来。

## 查到的真相

CI 日志里 `[rtchild] t started` 之后再无任何 round 行 —— **一轮都没跑到
provider**。所以不是输出丢包,是轮次根本没启动起来(CI 的 2 核 runner +
race 插桩下启动更慢)。

原写法把两件事塞进同一个固定 5 秒窗口:

    waitFor(t, "开始输出", func() bool { return len(dev.snapshot()) >= 2 })

「输出已开始」是**前置条件**,「Stop 后输出冻结」才是被测行为。混在一起后,
超时只能报"没等到输出",看不出到底是「轮次没启动」还是「输出没外发」。

## 改法(同 2026-10-24 TestStress_Nesting... 的先例:等状态,不等时间)

    waitForWithin(t, "轮次进入 ChatStream", 30s, p.callCount() >= 1)
    waitForWithin(t, "开始输出(chunk 到设备)", 30s, len(dev.snapshot()) >= 2)

两段分开等、各自 30 秒:超时能直接指认卡在哪一步。被测断言一字未改。
顺带把原来丢弃的 `PushRTInput` 返回值改成检查(同文件第 297 行本来就这么写)。

## 另一处已定位但**没修**

`TestResidualDropNotifiesSyncCaller`:本地整包 `-race` 8 轮偶发 1 次
(`应处置 1 条,实际 0`),CI 从未出现。

根因是 `waitSchedulerStopped()` 只等 `running == nil`,**不等调度循环退出** ——
cancel 之后循环仍有窗口把测试刚入队的任务取走。要修得给生产代码加
「循环已退出」信号,不是改测试能解决的。已记入 `docs/zh/future-plan.md` §5.0。

## 验证

    go vet ./internal/agent/core/                  ✓
    go test ./internal/agent/core/ -count=1        ✓
    go test -race ./internal/agent/core/ -count=1  ✓(连跑 3 轮)
    单测 -race -count=5                            ✓
2026-10-31 19:16:58 +08:00
de1a8157f9 merge: 日志默认去掉源文件位置 2026-10-31 18:49:38 +08:00
0cb20fb7f5 fix(log): 生产日志默认不再带源文件位置
## 现象

生产日志每行都带 `file.go:NNN`:

    2026/10/31 18:47:03 bootstrap.go:320: [homed] shutting down...
    2026/10/31 18:47:03 builtin_skill.go:163: [plugin] seq: removed skill ...
    2026/10/31 18:47:03 sidecar.go:419: [clawhubadapter] sidecar manager stopped

来源是 `cmd/homed/bootstrap.go` 里无条件 `log.SetFlags(... | log.Lshortfile)`。

## 判断:生产确实不需要

- 多数日志**本身带模块前缀**(`[homed]` / `[plugin]` / `[kbtree]` / `[skillmgr]`),
  去掉行号后定位能力并不缺
- `Lshortfile` 在**每一次** log 调用里做一次 `runtime.Caller`,不为零
- 它还不能真的消歧:它只打基名,26 个插件里有多个 `plugin.go`,
  `plugin.go:104` 反而指不出是哪一个 —— 真正的消歧靠的是消息里的 `[插件名]` 前缀

## 改法

`logFlags()` 抽出来,默认 `Ldate|Ltime`;`HOMED_LOG_SOURCE=1`(或 true/yes/on)
时追加 `Lshortfile`。

保留开关而不是删掉:排查插件/装配问题时行号仍有用,部署方临时开一下即可。
用法写进 `docs/zh/deploy-runbook.md` §3.0。

## 判据

- `TestLogFlagsNoSourceByDefault`:默认不得有 Lshortfile,且日期时间必须保留
- `TestLogFlagsSourceOptIn`:开关的六种正向写法生效、四种关闭写法不误开

## 验证

    go build ./...             ✓
    go vet ./cmd/homed/        ✓
    go test ./cmd/homed/       ✓(含新增两条)
2026-10-31 18:49:36 +08:00
3da59bc92f merge: 知识库改词法为主 2026-10-31 16:57:31 +08:00
0365b08dc9 fix(knowledge): 改成词法路为主,RRF 方案实测无效
## 上一版(RRF)在生产上零收益

73bc3f9 用「稠密 1.0 / 词法 0.5 的 RRF」重启部署后,生产上 7 条查询
结果基本没变,目标条目一次都没进前三。原因是我在**本地退化路径**上验证,
而生产走稠密路径——两个分支的融合逻辑根本不同。

在真实 196 条库 + 真实 chineseclip 上测出两路的名次,问题一目了然:

    查询                        稠密名次   词法名次
    PruneOldBackups                103         1
    strip_path                     164         1
    minSceneEvidence               130         1
    coldcontext_query              149         1
    上下文裁剪的保护条数受什么约束    157         1
    冷上下文取回为什么不能先删后灌    158         1

词法路全中,稠密路全在 100 名外。稠密权重 1.0 时它的 `1.0/61` 永远压过
词法的 `0.5/61`,所以词法路精确命中也没用。

## 为什么连 RRF 本身也不行

试过把权重翻成词法 1.0 / 稠密 0.5,仍然错。因为 RRF **丢掉分数差距**:
`strip_path` 的 top1 是 0.056、top2 是 0.055 —— 名次差 1,RRF 当它们等强,
稠密对 top2 的加分一叠就反超。

## 现在的做法:fuseLexPrimary

- 词法路给主序,**保留分数差距**(这是关键词)
- 稠密路用于:同分裁决、补位、以及词法无信号时接管
  (`minLexSignal = 0.02`,低于此视为 jieba 泛滥词的微弱命中,让稠密接)
- 稠密结果仍然被使用,不是死代码

## 实测(真实库 + 真实模型)

    top1 = 10/13   top3 = 11/13

改前生产表现:7/7 全错。剩下的 3 条未命中(仓颉关键字 rank2、鸿蒙、更新日志)
是既有的弱点,与本次改动无关。

## 判据

- `TestDensePathFeedsRanking`:钉「稠密结果必须喂进排序」(改了形状,意图不变)
- `TestSearchDocReflectsFusion`:钉文档写明 fuseLexPrimary + 词法为主
- 文档注释里记下两次翻转(2026-10-08 → 10-31)与 RRF 被否的实测原因,
  避免下一个人再翻回去
2026-10-31 16:57:29 +08:00
73bc3f94b7 merge: 知识库标识符检索修复 2026-10-31 16:42:38 +08:00
0ca9e96290 fix(knowledge): 修复英文标识符检索不到(分词器 + RRF 融合)
## 故障

真实库 196 条里 154 条是 cangjie 文档(1.53MB / 88.6% 字节)。用户反馈
「语义检索命中偏弱,cangjie 霸榜」。实测三条带精确标识符的查询:

    PruneOldBackups   → top5 全是 cangjie,目标条目一次没出现
    minSceneEvidence  → 同上
    strip_path        → 同上

## 真因不是 cangjie,是分词器

cangjie 只是把问题放大的体量。真正的洞在 `memory.TokenizeWords`(jieba)
对英文标识符的处理 —— 实测:

    PruneOldBackups → [P r u n e O l d B a c k p s]   ← 切成单字符还去重
    strip_path      → [s t r i p _ a h]

于是任意两篇含英文字母的文档 token 重叠率都能到 50%,词法路彻底失效。
而稠密路(chineseclip)对「只在文中出现一次的专名」本就没有分辨力。

这解释了 2026-10-08 那次决策:当时把词法路从融合里去掉,理由是
「词法路对英文标识符失效」—— 观察对,**归因错**:失效的是分词器。

## 改法

1. `identtoken.go`(新):知识库词法路专用分词器
   - 补标识符**整词**:`PruneOldBackups` → `pruneoldbackups`
   - 补**分隔符部件**:`strip_path` → `strip` / `path`(仅当下划线等存在时)
   - **不拆 camelCase**:拆了会产出 `old`/`backups`,而 `old` 在
     `cangjie/tools/cmd-tools/cjcompat_manual` 里是 CLI 参数名 `--old`,
     出现 31 次 —— 实测评测中该文档得分 0.54 > 目标的 0.20,正好把无关文档顶上来
   - 丢掉 jieba 切出来的单字母残渣
   - 中文分词完全沿用 jieba,行为不变
   - 只注入知识库的 veczer,**不动** memory 索引/关键词提取

2. `fusion.go`(新):RRF 倒数排名融合
   - `score = 1.0/(60+rank_dense) + 0.5/(60+rank_lex)`
   - 不用当初那套「逐路按查询内最大值归一化」:那个做法把 0.02 的噪声抬到
     与 1.0 等权,正是 2026-10-08 淘汰融合的真凶。RRF 只看名次,与量纲无关
   - 稠密权重 1.0(语义主线)/ 词法 0.5(精确命名救助)

3. `Search` 文档注释重写:把两段历史都写清(2026-10-08 为何淘汰、2026-10-31
   为何加回但换成 RRF),避免下一个人重复踩。

## 判据

- `TestIdentifierTokenFeedsLex`:钉分词结果(必须产出整词、不得拆出 old)
- `TestIdentifierQueryBeatsVolume`:60 篇英文词密集的噪声文档 + 一条只在
  目标条目出现一次的标识符,top1 必须命中目标 —— 分词器一退化就红
- `TestSeparatorIdentifierNotBuried`:带 `_` 的标识符,整词要压过部件噪声
- `TestDensePathFeedsRanking`(原 `TestDensePathIsSoleRecall`):保留它真正的
  意图 —— 防「调用了 denseHits 却把结果丢掉」这个反模式,形状改为喂给 fuseRRF
- `TestSearchDocReflectsRRF`(原 `..DenseOnly`):钉文档与实现不漂移

## 验证

    go build ./...                        ✓
    go vet ./internal/knowledge/          ✓
    gofmt -l                              ✓
    go test ./internal/knowledge/         ✓(含 -race)
    go test ./... -count=1                全绿

⚠️ 生产实例需重启才能生效(lex 索引在启动时重建,无数据迁移)。
2026-10-31 16:42:36 +08:00
cc07adb20b style(webui): dashboard.js 格式化(换行与引号风格)
编辑器/格式化器在保存时改的,只动换行与引号,无语义变化:

  · 一行多条件的 if 拆成多行
  · 含双引号的字符串改用双引号包裹(避免转义)
  · 长字符串拼接拆分

13 insertions / 4 deletions,逐行核过无逻辑改动。
2026-10-31 16:20:47 +08:00
47cdb03918 merge: pr31 update 回滚安全 2026-10-31 15:56:11 +08:00
f9ee58a914 merge: pr31 — update 回滚安全(原子写 + 回读校验),解 19 处冲突
pr31 与 main 是**双向分叉**,不是单向落后。`ba281a8` 已把它的多数工作
在 main 上重新实现(dataDir 守卫、gc.go、dataDir 解析),但两边各自又
往前走了,所以 6 个文件 19 处冲突要逐条判。

## 合进来的(main 缺)

- **标记原子写**:main 是 `os.WriteFile` 直接覆写,掉电留半截标记;
  改为 `写 .tmp → fsync → close → rename → fsync 目录`
- **H4**:`publishResult` 里 `if errMsg != "" { _ = errMsg }` 把失败原因
  丢了,订阅方只看到 ok=false。现在 error 字段恒定存在
- **回读校验补最低可用性**:main 对无 digest 的旧 marker 只比"内容与备份
  一致",而**零字节备份内容也一致** ⇒ 判成回滚成功、不清 marker 的时机
  也错过了。补"非空 + 带可执行位",刻意**不**要求 `--version`
  (老内核没这个 flag,那正是 PR #31 的回归)
- `kernel_apply_test.go`(836 行,含 B2c `TestMarkPendingUpgrade_PreservesOldMarkerOnFailure`)

## 保留 main 的(pr31 在这几处是旧版或更差)

- **回滚校验用 digest**(`verifyRestored`)而不是 `--version`:
  pr31 要求回滚后能读版本,而 1.3.x 及以前没有 `--version`,
  而它们恰恰是最常见的回滚目标 ⇒ 从旧版升级失败后永久回滚不了
- **`restoreDepGraph` 三态**:原来有+备份有→还原、原来有+备份无→不算成功、
  原来无→删掉新装的。pr31 只处理第一种,会导致「旧 homed + 新 depgraph」
  的崩溃循环
- **marker 在还原完整后才清**:pr31 在还原前就清,还原失败即失去第二次自愈
- **CLI 不重启服务**:pr31 仍带 `restartUnit`,会杀掉整个 cgroup 连带 guard
- **`resolveDataDir`**:pr31 的 `upgradeDataDir` 是它的子集,多扫几个候选前缀

## 冲突处理中的三处重复声明

两边各加了同名函数,自动合并把它们都留下了:

- `readBinVersionStrict` —— 删 pr31 的(main 在 77 行有)
- `kernelDataDirMu/kernelDataDir/SetKernelDataDir/KernelDataDir` —— 删 kernel.go
  里那份(main 在 install.go)
- `newPkgRoot` —— pr31 的重命名 `newPkgRootFromBin`,两套测试各自可用
- rollback.go 里 `verifyRestored` 与 `readBinVersionStrict` 两段校验并存 —— 删后者

顺带修掉删除时误伤的 `ShortSum` 注释,并删掉 pr31 引入但无人调用的
`DataDirEnv`(main 明确不再拿 env 当 dataDir 来源)。

## 验证

    go build ./...                        ✓
    go vet ./...                          ✓
    go test ./... -count=1                62 ok / 0 fail
    go test -race ./internal/update/ ...  ✓
    gofmt -l                              干净
2026-10-31 15:56:10 +08:00
58dea7e6c4 merge: 介绍站内容修正 2026-10-31 11:44:04 +08:00
aa9e8c7454 docs(site): 修正介绍站三处与代码不符的描述
## 1. 「一轮里的多个 tool_call 默认并发」写反了

同一张卡的标题是「同轮工具默认串行,声明后才并发」,正文却写「默认并发」。
按 `internal/agent/core/argvalidate.go:307` 的 `batchRunnable`:批内**每一个**
工具都 `ParallelSafe` 才并发,有一个没声明就整批串行,`Serial` 优先级更高。

改:标题保留,正文按实现重写。

## 2. 「最近 10 条恒受保护」已不成立

`effectiveProtectedCount` 现在返回 `min(配置值, ContextTokens/单条平均token)`。
固定 10 条在 50k 窗口、5800 token/事件时会占 58k,比 40000 预算还大 —— 这正是
召回率从 44% 掉到 22% 的那次实测。站点两处都还在写「恒受保护」,改成「受预算约束」。

## 3. 第二层命名与描述

- 「低分下沉成文档,冷文档再蒸馏」→ 冷上下文
- ② 层正文「JSON + TF-IDF 索引的中间层」→ 实际是稠密 + TF-IDF 双路,
  且与 L1 结构同构、取回走两阶段
- FAQ「上下文事件裁剪后归档成文档」同步改

全站 `文档层|冷文档|归档成文档|Document 层` 归零。
HTML 结构校验:div / section 配对差 0,`<p>` 开闭 62/62。
2026-10-31 11:44:04 +08:00
8a6e480175 merge: 架构节改参考文档写法 2026-10-31 11:42:23 +08:00
bd9dd9e75f docs: 架构节改成参考文档写法
上一版还是论证体:四个小标题里三个是否定式断言(「上下文**不是**截断+摘要」
「三层记忆是迁移,**不是**复制」「记忆按场面分,**不按**会话」),
每段都以一句评论收尾。段落字数 62–128,几乎等长 —— 这是最明显的机器痕迹。

改成参考文档:

## 小标题换成名词

   上下文不是「截断 + 摘要」  →  上下文窗口
   三层记忆是迁移,不是复制    →  三层记忆
   记忆按场面分,不按会话      →  场面

标题说明这节讲什么,不下判断。判断该由正文的数据支撑,不该写在标题里。

## 删掉与假想敌的对比

「常见做法是把超窗的对话裁掉或让模型自己摘要,两者的共同问题是丢什么由位置决定」
「普通 agent 通常一条会话一条历史,换个话题就失忆」

换成直接说我们怎么做的。读者不需要先被告知别人怎么错。

## 段落长度拉开

之前 62–128 字(极差 66)。现在每节先给一行文件路径,正文长短不一:

    32 字  `internal/agent/core/context.go`
   127 字  这条约束来自一次实测。50k 窗口……
    51 字  N 默认 10,实际取值受预算约束:……
   129 字  场面由五个特征构成……

极差 98 字。技术文档本来就是「一行路径 + 一段解释 + 一行数字」这种不均匀节奏。

## 去掉两处破折号

「没有『取出即删除』的路径 —— 删除排在确认之后」
「不吸收整轮指纹 —— 吸收了会……」

改成句号。全文破折号归零。

## 补上实现位置

原来只讲设计没给代码位置,改成每节标文件路径:
`internal/agent/core/context.go`、`internal/memory/coldcontext/`、
`internal/memory/scene_emerge.go`、`internal/agent/core/stages.go`。

## 核对

文中数字逐条对源码:`preemptPromotionCap = 2`、`minSceneEvidence = 2`、
`FindColdContexts(72*time.Hour, 2)` 均一致。两版 9 个标题对齐,无死链。
2026-10-31 11:42:21 +08:00
e542bc1643 merge: 介绍站命名同步 2026-10-31 11:41:18 +08:00
27f25bdc4f docs(site): 介绍站第二层改名 Cold Context
与仓库内文档同步:第二层早已改名 cold context(工具名 coldcontext_query /
coldcontext_commit),站点仍在用「Document 层」,且第二层描述写的是
「JSON + TF-IDF 索引」—— 实际是稠密向量与 TF-IDF 双路检索。

改了 6 处:meta description、三层配色注释、① 层说明里的归档目标、
② 层标题与正文、三层链路串。② 的正文同时换成当前事实:
与 L1 结构同构、稠密+TF-IDF 双路、取回走「先确认再删」两阶段。
2026-10-31 11:41:18 +08:00
4df8c73555 merge: 第二层改名 Cold Context + 许可段精简 2026-10-31 11:39:15 +08:00
7c0eec1b2f docs: 第二层改名 Cold Context,清理四组已不存在的符号名
## 问题

README 与两份 ARCHITECTURE / OVERVIEW 仍在用旧称「Document 层」,
而第二层早已改名 cold context(工具名 `coldcontext_query` / `coldcontext_commit`)。

不止是层名。文档里引用的四个符号在代码里**一个都不存在**:

| 文档里写的 | 代码里实际 |
|---|---|
| `docStore.ContextToDoc` | `coldcontextStore.ContextToColdContext` |
| `FindColdDocs` | `FindColdContexts` |
| `docToTriples` | `coldDocToTriples` |
| `docStore.SetVectorizer` | (已无此调用,embedder 构造期注入) |

四处都用 `grep -rn "\b名字\b" --include=*.go` 确认过:旧名 0 处,新名各自 4 处。

## 改了什么

- `assets/docs/zh/ARCHITECTURE.md` 13 处、`en/ARCHITECTURE.md` 15 处
- `assets/docs/zh|en/OVERVIEW.md` 各 3 处
- README 两版:「文档层」→「冷上下文层」
- `assets/branding/mascot-xiaozhai-guide.md` 的丝带配色表(三色分别对应三层)

## 顺带修正层号

ARCHITECTURE 用的是 **L1 Context / L2 Cold Context / L3 Stable**,
我上一版 README 写成了 L0/L1/L2 —— 少了一层,且与架构文档对不上。已改为 L1/L2/L3。

## 顺带修掉断掉的交叉引用

`internal/agent/core/toolcall.go` 与 `recallcoldcontext_test.go` 的注释写
「ARCHITECTURE「记忆流转」② Document 层的读取分支」,指的正是刚改名的节。
不改就成了悬空引用。三个测试函数名(`TestContextToDoc` 等)指向已删函数,一并改名。

## 验证

- 全仓 `grep` 旧名:`*.md` / `*.go` 均 0 处(排除 build/third_party/.pi)
- `go build ./...` ✓
- `go test ./internal/memory/coldcontext/ ./internal/agent/core/` ✓
2026-10-31 11:39:13 +08:00
6a2c907e04 merge: 架构节补上下文管理与记忆 2026-10-31 11:30:22 +08:00
152dc680ad docs: 架构节写清上下文管理与记忆的实际做法
## 补上真正的差异点

之前架构节只有一张图和七阶段表,把最核心的上下文管理省略了。
用 codegraph 查了 `RelevanceContext` / `PruneWithProtected` /
`coldcontext.Store` / `block_recall`,确认实际做法,逐条对照常见 agent:

### 上下文:按相关度取舍,不是截断或摘要

常见做法是超窗裁掉旧对话,或让模型自己摘要 —— 共同问题是**丢什么由位置决定**。
这里是给每条历史事件按当前输入打分,低分下沉到文档层。

打分主路径是稠密余弦(与媒体、文档同一向量空间),模型未就绪退到 TF-IDF。

关键在 `effectiveProtectedCount`:保护条数受预算约束。

```go
budgetCap = ContextTokens / avgEventTokens
pCount = min(配置值, budgetCap)
```

源码里记着这条约束的来历:50k 窗口下单条事件约 5800 token(工具回灌型),
预算 40000,「钉住 10 条」本身就装不下 —— 每轮裁剪都撞保护下限,
两轮后预算耗尽,任务直接终止,召回率从 44% 掉到 22%。

### 三层是迁移,不是复制

`ColdDoc` 的注释写得很清楚:「块的真实归属在 Events[i].Blocks 里」,
块在任一层只有一个持有者,不靠引用计数保活。

回灌用 `Peek` + 确认 + `Remove` 两阶段,源码明确标注「**禁止先删后灌**
—— 删除必须排在确认之后,回灌失败即永久丢数据」。

### 场面:两条路并行,且互不吞并

声明场景**不吸收整轮指纹**(`EnsureScene` 的注释):否则它会以接近 1.0 的相似度
吃掉后续所有同类轮次,被动聚类再也长不出更细的场面。

这条边界是踩出来的,不是设计时想到的。

## 开头相应收缩

原来开头四段「**粗体断言** + 三行解释」与架构节重复。改成一句介绍 + 安装 +
三段话点出差异,细节留给架构节。

两版 163 / 191 行,9 个标题对齐,代码块 10/10 配对,无死链。
2026-10-31 11:30:18 +08:00
5f570075a3 docs: 补回被误删的架构节,许可段压缩,去掉重复
## 上一轮我把架构整节删掉了

用 `s.index()` 替换区间时,把「架构」到「下载」之间的内容一起吞了。
README 里「架构」出现 0 次 —— 最该详细写的部分没了。已补回:

- 内核/插件三层通道图(ASCII,按 CJK 宽度对齐到 39 列)
- 七阶段表(`on_input` … `after_output` 各自时机与插件能做什么)
- 内循环规则与三个退出条件
- 三层记忆与插件崩溃退避(1s/2s/3s,5 分钟内第 4 次停自动重启)

## 许可段 16 行 → 7 行

原文逐条拆:

| 原文 | 问题 |
|---|---|
| 「GPL 家族里**传染性最强**的一档」 | 比喻+最高级,许可段不需要修辞 |
| 「即:任何人把改过的……」 | 重复上一句,只是换了说法 |
| 「边界很清楚:」 | 过渡铺垫 |
| 「子进程隔离在这里不重要了」 | 反驳没人提的问题(humanizer §A.5) |
| 「第三方插件生态的安全与活跃正建立在这条之上」 | 纯空话 |

用户只需要知道三件事:改过的版本对外提供服务要开源;自己写的插件不受约束、
可以闭源商用;第三方组件保留原许可。

## 去掉重复与模板化开头

- 「内核不发 HTTP、不读文件」在架构段与「其余」里各出现一次,删掉后一处
- 开头原本连续四段 `**粗体断言。** + 三行解释` —— 同一个模板重复四遍就是排比。
  改成一句话开场 + 安装命令,设计细节另起 `## 几个设计`
- 全文破折号从 1 处降到 0

## 验证

- 两版链接无死链,代码块配对(6/6)
- 6 个二级标题中英对齐
- ASCII 图逐行按 east_asian_width 计算,框线全部 39 列
2026-10-31 11:29:24 +08:00
7b4c6ead7f merge: README 写真正的独到设计 2026-10-31 11:27:20 +08:00
8a3151c285 docs: README 开头改成真正的独到设计
上一版开头是「接上 QQ、WebUI、命令行、文件、浏览器,跑几个月不用重启」——
这不是设计,是所有 agent 框架都能说的话。用 codegraph 查了实际实现,
真正独到的是四条,每条都有可验证的常量与实测值:

## 1. 启动顺序编译期定序

`internal/kernelboot/kernelboot.go` 的 `Module` 接口声明 `Requires`/`Provides`,
构建时 `homed-depgraph` 算出 6 层依赖图写入 `depgraph.bin`,并把 digest 用
`-ldflags` 注入二进制。运行期 `loadStartupPlan` 只读图。

关键在 `CheckAgainstBinary`:图与二进制 digest 不一致时**拒绝启动**,
不猜哪边对(注释原话:"猜一个是拿启动正确性赌运气")。

`Run` 里还有一层:`produced` 是**执行期事实**而非声明推演,`verifyLayerDependencies`
拿它校验,所以咬得住"层顺序被改坏"(实测 lua 排到 llm 之后 ⇒ llm 拿 0x0 LuaVM,SIGSEGV)。

## 2. 插件按设施就绪提前启动

`cmd/homed/kmod_runtime.go` 的 `BeforeRun` 钩子让插件调度器在**执行任何模块之前**
订阅 syncbus,`OnLayerDone` 在每层完成后重新评估拉起。

不是"内核装完再拉插件":声明 `requires_facilities: [config]` 的插件在第 1 层
完成时就起,不必等第 4 层 agent(实测 35 秒 = dense backfill 750 事件 + 重建索引)。

## 3. 两级输入、四级中断、饥饿防护

`internal/agent/core/scheduler.go`:排队输入无级别(任何中断可打断),中断带 L1–L4。
`effectiveLevel = min(L4, Level + min(PreemptCount, 2))` —— 抢占能提级但封顶两档,
`preemptPromotionCap = 2`,防低优先级饿死。

L4 内核独占,插件声明 L4 被 `clampPluginLevel` 夹到 L3。

## 4. 场面涌现

`internal/memory/scene_emerge.go`:指纹由 chan/peer/tool/topic/part 构成,
权重 1.0/1.0/0.8/0.4/0.2。`minSceneEvidence = 2` —— 只发生一次的不建场景。
`joinSceneThreshold = 0.5` vs `recallSceneThreshold = 0.35`,
**唤起比归属宽松**(注释:"想不起来是损失,多想起一条只是多几行上下文")。

QQ、WebUI、命令行这些搬到「其余」一节,不再当卖点。

## 验证

- `go run ./cmd/homed-depgraph -print` 确认 20 模块 6 层
- 四个常量在源码里逐一 grep 确认存在且值一致
- 两版各 98 行,4 节结构对齐,链接无死链
2026-10-31 11:27:16 +08:00
674cbac36c merge: README 重写为入口文档 2026-10-31 11:24:00 +08:00
52100dfa77 docs: README 改成 README 的样子
前三版都写成了演讲稿:一上来三段议论讲设计理念,用"它就在这些地方干活"
这种拟人句,把架构取舍当成卖点铺在正文里。420 行里真正有用的入口信息
(安装、命令、能力清单、文档链接)不到三成。

去看了几个热门项目的 README 怎么写的:

- **ollama**:`Start building with open models.` 一句,然后就是 macOS /
  Windows / Linux / Docker 四个安装块,接着 `ollama run gemma4`
- **caddy**:`Caddy is an extensible server platform that uses TLS by default.`
  一句,然后徽章、安装、命令
- **lazygit**:赞助商 + 截图 + 安装

共同点:**没有设计理念**。设计写进 docs 或 blog,README 只负责让人五分钟内
把东西跑起来。也没有"它"这个人称,直接说功能。

## 现在的结构

```
自托管 Agent 内核。接上 QQ、WebUI、命令行、文件、浏览器,跑几个月不用重启,还记得住事。

make build build-cli / ./build/homed / ./build/waiter
API 密钥在 WebUI 里填。需要 Linux。

## 能做什么        五行清单
## 架构            一张图 + 三段话
## 下载            三变体表格
## 构建
## 许可
```

100 行。原来 420 行。

删掉的内容去哪了:

- 设计取舍(为什么用子进程、`.so` 踩过什么坑)→ 已有一句压在架构段
  末尾,完整版在 `docs/zh/kernel-secure-firewall-design.md` 等设计文档里
- 长篇能力叙述 → 五行清单,细节在 SDK 文档

## 顺带修正

- "`internal/plugins/` 下 13 个内置插件" → 实际 18 个插件目录,改为不写具体数字
- 中文版重复的 `## 快速体验` / `## 文档`,英文版两个 `## Documentation`,
  合并后各留一处

## 验证

- 两版死链检查为空
- 代码块配对(6 / 8)
- 两版 5 节结构对齐

英文版同步,不是直译。
2026-10-31 11:23:56 +08:00
9fe82c8e26 merge: 介绍站标题与链接修正 2026-10-31 11:20:41 +08:00
9363660b79 docs(site): 改掉格言式小标题,修 21 个 404 链接,校正统计数字
## 小标题:格言改成陈述

原站的小标题全是这个调子:

```
崩一个,不赔上全部          拦得住,也接得回
三条道,各走各的            数据如水,随流,随改,随走
轻重缓急,各归其位          忙时,有人替你接住
部署,从未如此便捷
```

这是「句子很响,但读者看完不知道这节讲什么」。正文其实写得很扎实
(有退避 1s/2s/3s、L4 被夹到 L3、60 万 token 上限这些实测值),
只有标题在唱口号。

改成陈述式,读者扫一眼就知道这节讲什么:

```
插件崩溃不影响内核              中断可以抢占,也能恢复现场
三条通信通道各管一类数据        大块数据走共享内存,不序列化
输入分两类,中断分四级          积压的消息由驻留子 agent 接手
部署是拷一个文件
```

另外两处本来就重复:「三条道,各走各的」和「数据如水,随流,随改,随走」
讲的是同一件事(都讲共享内存)。前者改成只讲三通道分工,后者保留细节。

## 21 个 404 链接

「可用插件」里每个插件的源码链接都指向
`github.com/JianFeeeee/hologramagentsdk/blob/main/example/<name>`,
而 `example/` 下的 26 个目录已迁走并删除,**全部 404**。

改为 `github.com/HologramAgent/<name>`,逐仓 `git ls-remote` 验证过都存在。
徽章右侧的可见文字也从 `SDK / example/qq` 改成 `GitHub / HologramAgent/qq`。

## 统计数字虚高

按 `site/README.md` 自己的核对流程实测(生产日志
`homed_2026-10-31_10-05-16.log`):

| 页面项 | 原值 | 实测 |
|---|---|---|
| 在跑插件 | 36 | **30** |
| 可用工具 | 340 | **285** |
| Go 代码行 | 109k | **240k** |
| LLM 适配器 | 9 | 9 ✓ |

Go 行数是**反向错**的:核对命令没排除 `build/`,而 `build/` 里打包了 SDK 副本,
不排除会得到 516549 行,排除后 240056 行。已把该排除项写进 `site/README.md`
的核对命令,避免下次再算错。

数字校正清单也写进 `site/README.md`,标注核对日期与日志文件。

## 验证

- HTML 标签配对:div / article / a 全部为 0 差
- 行数未变(3417),改动集中在标题与链接
2026-10-31 11:20:37 +08:00
49e80307c7 merge: README 去 AI 味(humanizer-zh 规则) 2026-10-31 11:15:36 +08:00
28b78e1703 docs: 按 humanizer-zh 规则再改 README 开头
上一版开头四句被指出仍有 AI 味。拆开看问题在:

  · 四句长度接近、每句都是金句,没有一句是普通陈述
  · "说人话:"是元话语,AI 特有的"宣布要通俗化"动作
  · "白天聊过的事,晚上还记得" + "三周前说过的偏好,不会因为上下文窗口
    滚动就丢了" —— 严格对仗,一个口语一个技术解释混在一起
  · "插件崩了它不倒,模型换了它不改" —— 两组四字对仗

用 humanizer-zh 的规则过了一遍,命中三条:

1. **§A.1 不是 X,而是 Y** —— "跟直接调 API 的区别在于它把长期当成了
   基本需求,而不是事后加个向量库",后半句是假想敌。删掉后逻辑没损失。
2. **§A.2 单句收尾** —— "这个仓库从 2026 年 7 月开始开发,现在 1.4.0,
   一千多个提交" 独立成段作收尾。并入上一段。
3. **§A.5 与假想敌辩论** —— 小标题 "它跟别的 Agent 框架哪里不一样"。
   改成 "几个设计决定"。

另外自重写:

- 开头从四句金句改成一句陈述 + 一句具体 + 一段解释。不是排比。
- "## 能力" 从五项 "**类别**:三词·三词·三词" 改成有详有略的散文
  (五项同模板是最密集的 AI 指纹之一)
- "## 几个设计决定" 从五条 "**X,不是 Y。**" 改成一段段长短不一的叙述,
  其中 3 条原本是同一个句式
- 删掉与开头重复的记忆下沉描述

英文版同步,不是直译:`What makes it different` → `Design decisions`,
开头去掉同样的假想敌与收尾句。

## 修掉一处我自己写出来的事实错误

上一版我写了 "微信和 QQ 的文件、语音消息能收发"。核查后:仓库里没有微信
插件,只有 clawhubadapter 注释里提到微信作为通用通道的例子。已删掉微信。

## 验证

- 两版死链检查为空
- 代码块配对(14/14)
- 9 个二级标题中英对齐
2026-10-31 11:15:33 +08:00
8c72bd8ec5 merge: README 重写(中英对齐 + 历史移入 CHANGELOG) 2026-10-31 11:08:23 +08:00
7f43a6bfc7 docs: 重写 README,中英文对齐,历史移入 CHANGELOG
## 问题:README 像架构文档,不像 README

原文 420 行,开头第一句是「以核心域与应用域分离为设计原则的 Agent 框架」。
读者点进来想知道「这东西能给我干什么」,得到的是一句需要三分钟才能理解的定义。

量化一下 AI 味的几个典型指纹:

| 指标 | 改前 | 改后 |
|---|---|---|
| 加粗标记 | **230** 处 | 56 处 |
| 破折号插入语 | **17** 处 | 0 |
| 行数 | 420 | 204 |
| 「项目状态」占比 | 86 行(21%) | 移出 |

几乎每条 bullet 都是同一个模板:粗体术语开头 + 三行解释 + 收尾总结。
结构过度对称、每段都自我收尾,是 AI 写作最明显的痕迹。

## 改法:先说人话,再讲设计

开头四句:

> 一个能自己跑几个月、还记得住事的 Agent 内核。
> 说人话:你给它接上 QQ、WebUI、命令行、文件、浏览器,它就能在这些地方替你干活。
> 白天聊过的事,晚上还记得。三周前说过的偏好,不会因为上下文窗口滚动就丢了。
> 插件崩了它不倒,模型换了它不改。

然后把 `## 设计要点` 改成 `## 它跟别的 Agent 框架哪里不一样` ——从「我们有什么设计」
改成「你和别人有什么不同」。每条都给了**为什么这么选**,不是**我们做得多好**:

- 插件是独立进程 → 因为 `.so` 那套踩过 `dlclose` 空操作、cgo 超时杀不掉线程
- 内核不碰 IO → 好处不是干净,是能换掉整个交互层而不改内核
- 记忆会下沉 → 所以能跑几个月而不把历史全塞进 prompt

`## 能力` 不列 API,列「你能让它干什么」:说话的地方 / 看和听 / 动手 / 记住 / 调度。

## 删掉 227 行重复与历史

- **`## 架构图`(142 行)**:`ARCHITECTURE.md` 里已有完整的「消息处理流程」「7 阶段
  钩子」「三层记忆」,README 里这套是**逐字重复**。README 留一张 8 行流程图 +
  三段话讲清机制,细节指向架构文档。
- **`## 项目状态`(85 行)**:这是更新日志,不是 README。移入新建的
  `CHANGELOG.md` / `CHANGELOG_EN.md`。

## 中英文同步

`README_EN.md` 之前停在旧版式(439 行,含 `Architecture Diagrams` /
`Project Status`),与中文版结构完全不同 —— 不是这次才不同步,是**一直是两份不同的文档**。

本次按节 1:1 对齐(9 节完全一致),英文不是逐字直译,按英文习惯重写:

```
## 它跟别的 Agent 框架哪里不一样   →  ## What makes it different
## 能力                          →  ## What it can do
## 怎么工作的                    →  ## How it works
## 快速体验                       →  ## Quick Start
## 代码结构                       →  ## Code Structure
...
```

补上英文版一直**缺失**的 `## Quick Start` 整节(中文有、英文没有)。

## 顺带修正的事实错误

- 「内置 13 个插件」→ 实际 18 个插件目录(`webui`/`cli`/`files`/`cmd`/`mcp`/`kbtree`/`seq`/`skillmgr` …)
- 「参考 `example/` 目录下的 Go 和 Lua 示例」→ 26 个插件已迁到
  [github.com/HologramAgent](https://github.com/HologramAgent),`example/` 只剩 2 个

## 验证

- 两版死链检查:`README.md` / `README_EN.md` 内部链接全部有效
- 9 个二级标题逐一对齐
- 破折号插入语归零(英文剩余 4 处全是列表分隔符)
2026-10-31 11:08:12 +08:00
acaf0887c7 fix(webui): 聊天图片"加载成功却显示加载失败"——HTML 属性里的反斜杠转义
## 缺陷

生产截图:图片加载成功了(img 元素在、src 正确),界面却多显示一行
蓝字「图片加载失败"/>」。

根因是内联事件属性里用了反斜杠转义:

    onerror="this.parentElement.innerHTML='<span class=\"att-err\">图片加载失败</span>'"

**HTML 属性值里没有反斜杠转义** —— 那个 \" 就是引号本身,属性在
`<span class=` 之后就闭合了。浏览器切出来的是:

    onerror = "this.parentElement.innerHTML='<span class="

剩下的 `att-err">图片加载失败</span>'"/>` 变成**文本节点**直接显示。

症状极具误导性:看起来像"图片坏了",实际图片是好的;而那个 onerror
因为语法错误**永不执行** —— 真失败时反而什么都不显示。

## 修法

不用内联属性(那要求以后每次拼 HTML 都记得转义规则),改成
**捕获阶段的事件委托**:

- error 事件**不冒泡**,只能在捕获阶段拿到(注释里写明了)
- 用 createElement + textContent 构造占位,不再拼 innerHTML 字符串
- 只处理 .chat-attachment-img,不影响正文里的 markdown 图片

## 顺带:两个类没有 CSS

`chat-attachment-img` 与 `att-err` 只出现在 JS 里,CSS 里一条都没有。
图片靠内联 style 撑着还能看,失败占位则完全没有样式。已补。

## 判据(2 条,变异验证)

- `TestChatAttachmentImgHasNoBrokenInlineHandler`:不许内联 onerror、
  必须用 DOM API、必须有 error 监听
- `TestDashboardNoEscapedQuoteInHTMLAttributes`:通用扫描,
  任何 `name="...\"...` 形态都会红(下次别人写内联事件也拦得住)

变异:把内联 onerror 放回去 ⇒ 两条同时红。

★ 写判据时踩到的坑(都已修):判据被**自己的注释**打红(注释里必须写出
错误写法来解释),需先用已有的 stripJSComments 剥离;另外我先自己写了一个
stripJSComments,与 dashboard_js_scope_test.go 的重名 —— 应复用已有的。

## 顺带修一条脆弱判据

`TestRTChild_RapidInputCollapsesToLatest` 全量跑时偶发红
("连发 50 条却跑了 5 轮")。

探针查实:连发 50 条只花 ~80µs,而测试 provider 的 ChatStream **同步瞬间
返回**,两者同量级 ⇒ 消费方能在连发过程中抢到几次执行。探针显示
dropped=48,collapse 明明生效了。

所以 `Rounds > 3` 是在测调度抖动,不是测契约。改为断言**覆盖主导**
(dropped >= burst/2)。真实通话每轮几十毫秒,连发微秒级,生产里
"轮次≈1" 自然成立。

变异验证:让 dropped 不计数 ⇒ 判据红(dropped=0(连发 50 条))。

go build/vet/gofmt 干净;go test ./... -count=1 -race = 62 ok / 0 FAIL / 0 RACE
2026-10-31 10:03:43 +08:00
843563120b merge: 安全策略收敛到 security 模块 + 文档全量校订
用户裁定驱动的两项架构修正,以及随之而来的文档体系重整。

## 一、安全策略收敛(3 个提交)

### 1d007e1 工具策略闸真的接上了
生产日志实证 691 个文件里 `denied by policy` 命中 0 次。
根因不是「buildAgent 少传一行」,而是 `kernelmod/agent` 的
`Requires()` 没声明 `FacSecurity` —— 依赖图没理由把 security
的产物交给 agent(只有声明了的 device 拿到了)。
同时发现子 agent(resident/rtchild)各自 New 时都没传 Security,
从构造起就是 nil。

### e78a38b 父为子声明工具策略
修正上一版「子继承父权限」的错误模型。用户裁定:应当是父 agent
为子 agent 声明策略。用的是树里早就有的 `CheckToolFor` /
`PrincipalPolicy` —— 机制完整但零生产调用方。

### 5194a96 安全策略全面收敛到 security 模块
security 是外围内核到核心内核之间的防火墙,自己提供接口、
判定全部内聚在自己内部。调用方只提供事实(谁在跑、有没有父、
原始载荷),不提供结论(我是哪类主体、该走哪条判定链)。

### 8293282 每 agent 一个策略实例
用户裁定「每个 agent 一个,分为 sessionProfile 和 longtimeProfile」。
切分原则是共享注册表 + per-agent 执行视图 —— 整份复制 Runtime
会让插件往 A 声明的档位对 B 不可见。优先级 session > longtime,
反过来就是提权。

## 二、文档体系重整(4 个提交)

### b9b5ac0 建立 future-plan 作为唯一未做登记处
此前「未做」散落各文档,时间一长就与代码脱节。

### cf14b84 / 4963d70 补齐状态分类 + 逐篇核实
32 篇文档此前混着三种时态。逐篇对代码核实,修正 6 处与代码不符
的自述(最危险的是 prod-migration-checklist 写着「已停机」,
那是一次性迁移当场的状态)。

### a402bfd 删除 7 份已完结的执行计划
执行计划在实施完结后变成误导性读物。删前提取三类内容:
教训 → 新建 engineering-lessons.md;
实测数据 → 并入设计文档附录 A;
未完成项 → future-plan.md。
保留 legacy-table-retirement.md 与 interrupt-subsystem-split-plan.md
(被生产代码注释引用,是依据而非计划)。

## 门禁

- go build ./... ✓
- go vet ./... ✓
- go test ./... -count=1:10 轮全绿 ✓
- -race(agent/core + security + kernelmod/all)✓
- SDK 接口冻结检查:third_party/homeagent-sdk/sdk/ diff 为空 ✓
2026-10-31 09:19:24 +08:00
a402bfd777 docs: 删除 7 份已完结的执行计划,把教训与数据提出来
## 为什么删

执行计划在实施完结后**会变成误导性读物**:它记录的是"当时打算做什么",
而读者会读成"现在是什么"。

这在本仓已经造成真实误读 —— `prod-migration-checklist.md` 写着
「状态:HologramAgent 已停机」,那是一次性迁移(2026-10-04)当场的状态,
但照着读会以为服务现在还停着。

## 删了 7 份,删前提取三类内容

| 删掉的文档 | 行数 |
|---|---|
| `memory-restructure-plan.md` | 168 |
| `memory-blockflow-repair-plan.md` | 450 |
| `memory-usability-plan.md` | 309 |
| `scene-memory-fix-plan.md` | 271 |
| `toolcall-parallel-execution-plan.md` | 578 |
| `prod-migration-checklist.md` | 157 |
| `entities-retirement-plan.md` | 118 |

**提取去向**:

1. **教训 → 新建 `engineering-lessons.md`**

   这些是它们唯一还有长期价值的部分。汇总成一篇,并按形态归类:

   - 判据类(5 条):结构判据不能替代质量判据 / 测「接线之后」≠测「有没有接线」/
     验证通过≠架构正确 / fixture 照臆测字段编 / 先改代码再写测试
   - 接线类(3 条):机制≠接线 / 依赖图是唯一保证顺序的地方 / 单数改了复数漏了
   - 权限与生命周期(3 条):「接上了」有三层 / 以调用方名字为键的表要清理 /
     提权口子:让调用方声明自己是谁
   - 流程类(3 条):长任务纪律 / 数字要现场取 / 计划不进仓库

   文末点出它们共同的形态:**某种"看起来对了"的信号与"真的对了"之间存在缺口。**

2. **实测数据 → 并入 `toolcall-contract-and-sequence-design.md` 附录 A**

   那是新旧内核的对比压测(288 条输入 × 两版),核心结论值得留:

   ```
   场景        旧版中位/工具数    新版中位/工具数
   slowbatch8  0.220s / 0 个     0.409s / 8 个
   ```

   ★ **旧版"更快"是假的** —— 它的 `openai.lua` 缺 `stream_index` 透传,
   多个分片并到槽 0 ⇒ 每个工具报参数错误而一个都没真跑。
   这是「只看耗时会被骗」的典型。

   另附 stage 锁告警的完整归因(不是内核缺陷,成因在插件进程侧运行时)。

3. **未完成的项 → `future-plan.md` §六**

   尤其 `entities-retirement-plan` 那条:方案是"三表全退",但 2026-10-31
   实测生产库 `entities 1292 / relations 978 / sentences 66` 行**仍在用**。
   这个事实不能随文档一起消失。

## 保留两份(它们不是计划,是依据)

- `legacy-table-retirement.md` —— 被 **4 处生产代码注释**引用(`graph.go` ×2、
  `commit_blocks_test.go`、`probe_prod_test.go`),是退役判断的依据
- `interrupt-subsystem-split-plan.md` —— 探测事实被 `interrupt_invariant_test.go`
  引用;拆分本身确实未实施

## 索引重写

`docs/zh/README.md` 现在是「四类文档」的规则说明:

| 类型 | 回答什么 | 命名 |
|---|---|---|
| 设计文档 | 为什么这样设计 | `*-design.md` |
| 操作手册 | 怎么做 | `*-runbook.md` |
| 教训 | 踩过什么坑 | `engineering-lessons.md` |
| 未做登记 | 还剩什么没做 | `future-plan.md` |

并明确写「**不放执行计划**」及例外条件。

## 顺带修掉的引用

删除后 4 处引用指向不存在的文件:

- `deploy-runbook.md` → 改指 `toolcall-contract-and-sequence-design.md`
- SDK 仓 `guide/stream-tool-call-index.md` / `guide/parallel-tool-declaration.md`
  → 同样改指(这两个在 SDK 仓,随后单独提交)

## 验证

- docs/zh 27 篇,索引里列的全部存在、无遗漏
- 全仓无指向已删文档的引用(代码 + 文档)
- 代码块全部配平
- go build 通过
2026-10-31 08:50:39 +08:00
4963d70215 docs: 逐篇核实全部文档的完成状态 —— 修正 6 处与代码不符的自述
## 上一轮的遗漏

上次只补了"缺状态行",但**32 篇正文里的状态声明基本没动**。而那些声明
大多是 1~2 周前写的,代码早已推进。本轮逐篇对代码核实。

## 逐篇核实结果(方法:读当前代码,不看文档的历史描述)

### 1. memory-usability-plan:待执行 → 已实施

文档写「状态:**待执行**(2026-10-22)」。三条根因实测**全部已修**:

| 根因 | 实测 |
|---|---|
| R1 向量化输入是 jieba 词袋 | ✅ `coldcontext.go` 的 `denseFor` 明写「用事件正文(原文),不用 Summary(jieba 词袋)」 |
| R2 媒体块无语义 | ✅ 块 union 化,媒体即块自带描述 |
| R3 提示词无引导 | ✅ `tooldefs.go` 明写 `coldcontext_query` 把历史**插回时序**,并下沉到 kernel-memory skill |

### 2. entities-retirement-plan:执行中 → 方案已定、执行未完成

写「三表全退,执行中」,容易被读成"快完成了"。实测生产库:
`entities 1292 / relations 978 / sentences 66` 行**仍在用**。
迁移工具就绪,但生产未切换 —— 这个区别对判断风险很关键。

### 3. scene-memory-fix-plan:部分落地 → 7 步里 5 步完成

逐条核实:步骤 1/2/3/5/6 已完成(含 SDK 的 `ScenePolicy`、
`scene_emerge.go` 已区分 `peer:group_*` 与 `peer:user_*`);
步骤 4(清现网垃圾场景)与 7(收口验证)属现场操作,未复核。

### 4. prod-migration-checklist:⚠️ 最危险的一处

写「**状态**:HologramAgent 已停机(inactive + disabled)」——
那是一次性迁移(2026-10-04)当场的真实状态,**不是现在的**。
照着它读会以为服务停着。改为明确归档说明 + 指向现行 runbook。

### 5. abstention-criterion:「拒答尚未接入生产路径」已不成立

实测拒答已下沉到图库层(`RecallBlocksGuarded`),生产路径
`toolcall.go:1312` 与端到端探针**走同一条路**。保留了下沉的理由
(第一版放上层,探针绕过它 ⇒ 判据测不到被测路径)。
剩余两项(泛指词检测、n=10 样本偏小)仍开放。

### 6. toolcall-parallel-execution-plan:阶段 0.5 起为待办 → 全部已完成

实测阶段 0/0.2/0.5/1/2/2.5 与 3/4 全部完成,遗留项也已解决。

## 顺带修正两处写法

- `memory-restructure-plan.md`:顶部有「执行结果摘要」但正文各步骤仍写
  `✗ 未开始`(原始计划快照,有意保留)。加一句读法说明,
  避免读者拿正文的快照当现状。
- `future-plan.md` §六:新增 5 条实测确认的待办并去掉 2 条重复项
  (遗留表退役、中断子系统原先各出现两次)。

## 核对

- docs/zh 32 篇全部有状态行,且与代码实测一致
- 代码块全部配平;文档间相对链接全部有效
- go build 通过(纯文档改动)
2026-10-31 08:20:47 +08:00
cf14b840ef docs: 补齐文档状态分类 + 校正两轮代码改动后的失真
## 背景

上一次文档校订(b9b5ac0)之后又有两次代码提交(5194a96 / 8293282),
把 future-plan §1.1 整节变成了错的 —— 它还写着「档位没有生效点」
和「在 prepareInputTask 里加一次 activateSecurityProfile 调用」,
而后者正是被后来的收敛**否定掉的方案**。

## 新增 docs/zh/README.md(文档索引)

32 篇文档此前混着三种时态(当时实测 / 历史推演 / 设计规划),外观相同。
索引给出**状态分类与可信度规则**:

| 状态 | 能不能当事实用 |
|---|---|
| 已实现并部署 | ✅ 直接信 |
| 部分实现 | ⚠️ 只信标了"生效"的部分 |
| 设计 / 未实现 | ❌ 只能当设计意图 |
| 归档说明 | ❌ 不要改、也不要信 |

并写死**判据名可信度规则**:文档里的 `TestXxx` 不保证还存在。
给了核对命令,以及当前基线(27 条缺失的分布 —— 18 条属"设计未实现"类
文档的规划名、7 条是 §2.7.3 有意保留的历史对照表左栏、2 条属视频能力
未纳入契约)。判定标准:**在"已实现"类文档正文里找不到才是缺陷**。

## 重写 future-plan §1.1(已结案)

原节写的是错的方案。改成记录**真正的缺陷分三层**:

```
第 3 层(表面):activateSecurityProfile 零调用方
第 2 层:agent/core 在替 security 做策略决策
         (自己抠 security_profile / 自己选判定链 /
           自己写 Kind:"resident" 声明主体类别 —— 提权口子)
第 1 层(根因):security 缺少「输入接入点」
```

并补上「每 agent 一个实例」的最终形态(共享注册表 + per-agent 执行视图,
sessionProfile / longtimeProfile 两层,优先级 session > longtime 本身是安全语义)。

§〇「机制 ≠ 接线」表格里档位那一条从 🔴 改为 ✅。

## 各文档状态行

此前 14 篇缺状态行(runbook / 实测报告 / 计划类)。逐篇补上**符合实际内容**
的声明,而不是统一套一句。特别地:

- `commondev-design.md` 标题本就写「设计 · 未实现」→ 归到"设计"类
- `live-run-evidence-2026-10-04.md` → 明确标注"历史证据快照,不要用它的
  结论覆盖现在的实现判断"
- `legacy-issues.md` → 写明它与 future-plan 的分工(前者收已知问题,
  后者收能力缺失与零调用方)

## 正文里两处判据名漂移

- `TestBootCtxCloseAllIsReverseOrder` → 实际是
  `TestKernelStateCloseAllIsReverseOrder`(`BootCtx` 改名时判据跟着改的)
- `TestNilTypedBusSafety` → 标注为"规划名,仓库中不存在"

## 新增 §2.7.5 模块产物的实例化粒度

依赖图保证"设施已就绪",但没说该有几个实例 —— 这是独立决策。
记下 security 的两个答案:全局基线共享(否则插件声明对部分 agent 不生效)、
轮次状态 per-agent。并给出给未来新模块的两条判断标准。

## 安全文档

状态头补「架构形态」段(防火墙定位、per-agent 视图、两层档位);
接线状态表里 `ActivateProfile` 从 🔴 改为 ✅(2026-10-31 接通)。

## README

文档列表加索引与 future-plan 入口 —— 此前从 README 找不到 docs/zh 的任何指引。

## 核对

- docs/zh 32 篇全部有状态行
- 索引里列的文档全部存在,无遗漏
- 分类与各文档自述一致(逐篇抽查)
- go build 仍通过(纯文档改动)
2026-10-31 07:50:58 +08:00
82932821c0 feat(security): 每 agent 一个策略实例 —— sessionProfile / longtimeProfile 两层
## 用户裁定

> 每个 agent 一个,分为 sessionProfile 和 longtimeProfile

## 收敛前的缺陷

档位挂在 `Runtime.active`(**进程级单例**)上,多 agent 并发时串档:

	主 agent 轮次 A:注入 qq.guest  →  active = "qq.guest"
	驻留子 轮次 B:注入 qq.owner    →  active = "qq.owner"     ← 覆盖 A
	主 agent 轮次 A 继续执行工具     →  判定按 owner 走        ← 串档

这不是理论风险:驻留子 / rtchild 与主 agent **同进程并发**(plan.md 十一)。

## 切分原则:共享注册表 + per-agent 执行视图

**不能整份复制 Runtime** —— `profiles` / `principals` / `scopes` 是插件
声明出来的注册表,每个 agent 各持一份就会变成「插件往 A 声明了档位,
B 看不见」,那是最难查的一类缺陷。

	共享(进程级 1 份)              per-agent(N 份)
	────────────────────────        ───────────────────
	gate / policy / content          sessionProfile   ← 轮次级
	profiles / principals            longtimeProfile  ← agent 生命期
	scopes / eventPolicy

## 两层档位语义(用户裁定)

	sessionProfile  —— 属于**这一轮**。输入携带它,End() 即清。
	                   严格 per-round:同一用户下一条消息要重新声明。
	longtimeProfile —— 属于**这个 agent 的一段生命**。设置后跨轮保留,
	                   直到显式改掉或 agent 销毁。

**优先级 session > longtime**,这个顺序本身是安全语义:

	session 是"这一轮的事实",必须能盖住 long-time 默认值 ——
	否则访客发一条消息也无法在长期为 owner 的 agent 上收窄权限。

	反向(longtime 盖 session)= 提权:长期 owner 的 agent 上,
	访客声明的 guest 被吞掉 ⇒ 访客拿到 owner 权限。

## 改动

① 新增 `internal/security/agentscope.go`:

	rt.ForAgent(agentID, isChild) *AgentScope   // 幂等
	sc.BeginRound(RoundInput)                   // security 自己解析档位
	sc.SetLongtimeProfile(name) / LongtimeProfile()
	sc.EffectiveProfile()                        // session > longtime
	sc.CheckTool(tool)                           // 全局基线 + 档位 + 主体策略
	rt.ReleaseAgent(agentID)                     // 含 longtime 档一起清

② `Runtime` 新增 `scopesByAgent` 表;判定拆出 `checkToolWithProfile(p, profile, tool)`
   与 `checkToolForWithProfile(...)` —— **档位由调用方显式传入**,
   不再一律读全局 `r.active`(否则 per-agent 视图白做)。

③ `agent/core`:`securityScope()` 用 `sync.Once` 缓存本 agent 的视图;
   `Stop()` 里 `ReleaseSecurityScope()`。

④ `Round.End()`:per-agent 路径只清 **session**,**保留** longtime;
   旧进程级路径仍恢复进入前的值。

## 判据(3 个变异全部实测咬住)

| 判据 | 变异 |
|---|---|
| `TestAgentScopesDoNotShareSessionProfile` | ForAgent 退化为共享视图 ⇒ 红 |
| `TestLongtimeProfileSurvivesRounds` | 优先级写反 / longtime 不跨轮 ⇒ 红 |
| `TestReleaseAgentDropsLongtimeProfile` | ReleaseAgent 不删视图 ⇒ 红 |
| `TestLongtimeProfileRejectsUndeclared` | 未声明档位被接受 ⇒ 红 |

第一条同时钉:① 各自档位是自己的 ② **行为上**主 agent 仍被 guest 收窄、
子 agent 不受影响(只断言字符串相等会漏掉判定链没接上的情形)。

## 实测

- 真实并发探针:3 个 agent(guest / owner / guest)各 200 轮并发,
  `-race` 干净,**零串档**
- build / vet / go test ./...(62 ok / 0 fail)/ -race 全绿

## 未变的约束

档位与主体策略**只能收窄**:`CheckToolFor` 内部先跑全局策略,
所以父的黑名单对子依然有效,子无法提权。
2026-10-31 07:24:03 +08:00
5194a96560 refactor(security): 安全策略全面收敛到 security 模块 —— agent 只提供事实,不再做策略决策
## 架构定位(用户澄清)

security 是**外围内核到核心内核之间的防火墙**:自己提供接口、判定内聚在自己内部。
device 是设备抽象模块、持有 channel,两者不冲突。

现成的成功范例就在树里:`events.Bus.SetGuard` + `security.AsEventGuard` ——
总线只交事件,guard 自己判,总线一行策略都没有。本次把同样的形状套到输入/主体上。

## 收敛前的问题

`agent/core/security_profile.go` 214 行里绝大部分是**策略逻辑**:

	name, _ := payload["security_profile"].(string)   // ① 解析 security 的协议
	a.security.ActivateProfile(name, 0)
	if a.parentID != "" {                             // ② 选哪条判定链
	    return a.security.CheckToolFor(Principal{Kind: "resident"}, tool)
	}
	a.security.DeclarePrincipalPolicy("id", id, PrincipalPolicy{...})  // ③ 拼策略结构

三个后果:

① **协议解析外泄**:`security_profile` 是 security 的概念,解析却在外人手里
② **策略决策外泄**:判断"父走 CheckTool / 子走 CheckToolFor"是策略语义
③ **提权口子**:`Kind: "resident"` 是调用方**声明自己的类别** —— 说自己是
  内部主体就享受内部豁免(不过 HardPrivateTools)。类别必须从事实推出

且三条输入路径(主 agent / 驻留子 / rtchild)各自得接一遍,接漏一条就静默失效。

## 收敛后的形态

security 新增 `round.go`(自己提供接口):

	RoundInput{AgentID, IsChild, Payload, Source}  ← 只有事实,**无 Kind**
	rt.BeginRound(in)  → security 解析档位 + 判定主体类别
	round.CheckTool(t) → 本轮判定(调用方不需要知道任何组合规则)
	round.End()        → 归位(防跨轮泄漏)
	rt.DeclareChildPolicy / RevokeChildPolicy      ← 子策略生命周期

agent 侧只剩:`beginSecurityRound(payload, source)` + `defer End()`。
`security_profile.go` 从 214 行策略逻辑退化为转发层。

顺带全局收敛 `Principal.Kind`:裸 string → 固定枚举
(KindInternal / KindSubAgent / KindUser / KindGroup),并新增
`ParsePrincipalKind` —— 拼错返回 false 而不是静默回退到某类别。

## 轮次边界接到**全部三条**输入路径

	task.go      runInputTask   ← 主 agent + 驻留子(各有自己的 Agent 实例)
	rtchild.go   rtRunOnce      ← rtchild 不走调度器,是独立的一条

★ 收敛把**判定逻辑**收到了一处,但"一轮何时开始/结束"仍得各路径自己声明 ——
只有该路径知道边界。所以判据必须钉"每条路径都声明了"。

## 判据(5 个变异全部实测咬住)

| 判据 | 变异 |
|---|---|
| `TestSecurityProfileFromInputChangesToolDecision` | BeginRound 不解析 payload ⇒ 红 |
| `TestProfileRestoredAfterRoundEnd` | End 不归位 ⇒ 红 |
| `TestUnknownSecurityProfileFallsBackWithoutPanic` | 拼错沿用旧档 ⇒ 红 |
| `TestAgentCoreHoldsNoPolicySemantics` | 把 `Kind:` / `CheckToolFor(` 写回 agent ⇒ 红 |
| `TestPrincipalKindIsDerivedBySecurity` | 分类规则错位 / 枚举接受拼错值 ⇒ 红 |
| `TestEveryInputPathDeclaresRound` | rtchild 路径漏 beginSecurityRound ⇒ 红 |

`TestAgentCoreHoldsNoPolicySemantics` 逐行扫描(跳过注释),禁四类形态:
`payload["security_profile"]` / `Kind:` / `.CheckToolFor(` / `PrincipalPolicy{`。

## 实测(真实整机装配)

	① security 自己解析出的档位 = "qq.guest"
	② security 自己判定的主体     = {ID:main Kind:user}
	③ 本轮判定 cmd_run            = Allowed:false
	   reason="tool \"cmd_run\" is disabled by profile \"qq.guest\""

## 已知限制(已写进 round.go 与 future-plan)

`Round` 是 per-round 句柄,但 `Runtime.active` 仍是**进程级单例**。
多 agent 轮次**交错**时无法表达两个不同档位(串行场景完全正确)。
彻底修法是把档位挂到 TaskFrame 上按任务隔离,需先改 CheckTool 签名。

build / vet / go test ./...(62 ok / 0 fail)/ -race(security + agent/core +
kernelmod/all)全绿;make build 通过。
2026-10-31 07:14:09 +08:00
b9b5ac0282 docs: 全量校订文档到当前代码状态 + 建立 future-plan 作为唯一未做登记处
## 为什么

文档里混着三种时态:当时实测、历史推演、描述现状但已失真。三者外观相同,
读者无从分辨。本次逐条用实测核对,把失真的改成事实,把「已做」从「未做」
里摘出来,把「真没做」集中登记到一处。

## 新文件:docs/zh/future-plan.md

此前「未做」散落在各设计文档的「待办/剩余/遗留」小节,时间一长就与代码
脱节(已完成却仍写未做,未做却被当成已完成)。新建本文作为**唯一登记处**,
收录规则明确:只收「代码里确实没有」与「有机制但零生产调用方」两类。

它同时把本仓一个反复出现的缺陷形态显式化 —— **机制 ≠ 接线**。已攒够 5 例:

| 能力 | 曾经/现在的状态 |
|---|---|
| `Policy.CheckTool` | 曾 `Agent.security` 恒 nil → 2026-10-30 修 |
| `CheckToolFor` | 曾 0 生产调用方 → 2026-10-30 修 |
| `ActivateProfile` | **现在仍 0 调用方** |
| `ScanContent` | **现在仍 0 调用方** |
| `CheckPath` / `CheckNetwork` | **现在仍 0 调用方** |

## 已失真的表述(逐条实测后更正)

**kernel-modularization-design.md**

- §2.7.4 原标题是「未做(Step 4b 的剩余)」,写着「main.go 仍走原来的串行代码,
  下一步是把 Run(plan) 真接进 main」。**实际 `main.go:229` 早已调用
  `RunKernelModules` → `kernelboot.Run`**,插件调度也已归位 plugin-host。
  改为「已完成」并给出两处代码位置 + 生产日志证据。
- §2.7 分层数字「6 层 / 17 模块,最宽层 9」→ 实测 **20 模块 / 6 层 / 最宽 10**。
  同时给出取数命令,并要求「数字一律现场取,不凭文档记忆」。
- §2.3.5 那段标着「当前已产出的分层结果(实测)」的 4 层 14 模块表,是
  2026-10-16 的推演。改为明确标注「已过时,仅作设计推演保留」。
- §2.7.3 判据表 7 条**全部不存在**(随 3864f36 删死包迁包时丢失)。改为
  左右栏对照:左边当时名字、右边现仓库真实判据,找不到对应物的 4 条明确标 ❌。
- 文件头补状态声明、与 future-plan 的分工、数字取数方式。

**kernel-secret-firewall-design.md**

- 状态从「已实现」改为「部分实现」。原来那句会让读者以为整套安全能力都在跑。
- 新增接线状态表:逐个写**实测的生产调用点**(不写 ✅/❌ 定性)。
  事实是:凭据三件套与工具闸生效;**敏感词/注入、路径沙箱、网络闸三条
  机制完整但零生产调用方**;档位声明侧通、激活侧零调用方。
- §5 T1–T10:T2/T3/T4 早已落地却仍挂在「待办」表里。改为现状列,
  并把真未做的 T5–T10 迁入 future-plan §1.5。

**对外文档的工具名**(外部读者正在看错的名字)

- `ARCHITECTURE.md` 6 处、`OVERVIEW.md` 1 处、`README.md` / `README_EN.md` 各 3 处、
  `resident-subagent-design.md` 2 处:`memory_*` / `doc_query` → `stablemem_*` / `coldcontext_*`
- README 的 v1.1.1 发布说明保留原名(那是历史事实),加一句现名注记

**input-scheduler-design.md / resident-subagent-design.md**

- 补状态头。input-scheduler 记入 rtchild 不走四级阶梯(单槽覆盖)这一下游差异;
  resident-subagent 记入「继承安全运行时实例但**不继承权限**」这一下沉事实。

## 未动的(有意为之)

历史归档文档(`legacy-*`、`*-plan.md`、`live-run-evidence-*`、
`production-recall-validation.md`、`context-overflow-l4-design.md`、
`entities-retirement-plan.md`、`memory-*-plan.md`)**一律不改** ——
它们是历史记录,改了就篡改证据。其中的待办以索引形式登记在
future-plan §六,原文档保留。

## 核对结论

- 文档现在只描述两种内容:**已落地的实现**(附实测证据/取数命令)与
  **尚未实现的事项**(统一在 future-plan)
- 数字类断言全部给出取数命令
- 跨文档的「未做」引用统一指向 future-plan,避免再次分叉
2026-10-31 06:58:17 +08:00
e78a38b3d8 fix(security): 父为子声明工具策略 —— 修正上一版「子继承父权限」的错误模型
## 上一版错在哪

上一版(1d007e1)给子 agent 加了 `Security: a.security`,判据也钉的是
「子持有**同一个** Runtime 实例」。

那只保证了**机制**到位,却把权限模型写错成了「子 = 父的完整权限」。
用户裁定:**应当是父 agent 为子 agent 声明策略**。

区别是实质的:

	错的模型:父能调 cmd_* ⇒ 子也能调 cmd_*(子只受全局策略约束)
	对的模型:父在创建子时决定这个子能调什么,内核持有并执行,子不能自己扩大

驻留子长期无人盯、rtchild 通话中一边听一边执行工具 —— 它们恰恰最需要
**比父更窄**的权限,而不是与父等权。

## 这不是新机制,是没接线的旧机制

`security.Runtime` 里早就有:

	CheckToolFor(p Principal, tool)              // 按主体判定
	PrincipalPolicy{AllowTools, DenyTools,       // 主体策略
	                HardPrivateTools}
	DeclarePrincipalPolicy(scope, key, policy)   // 声明入口

而 `codegraph_callers("CheckToolFor")` = **0 个生产调用方**
(与工具策略闸、ScanContent 同型:机制完整、接线缺失)。
公开 SDK 也没暴露 `DeclarePrincipalPolicy`(插件暂时无法声明主体策略)。

## 改动

① `security`:新增 `RevokePrincipalPolicy(scope,key)` / `HasPrincipalPolicy(id)`。
   **撤销是必须的**:主体 ID 是调用方给的名字(`create id="bot1"`),
   子回收后同名重建是常规操作,旧策略留着 ⇒ 新一代用上一代父留的约束运行。
   这与今天修的 rtchild 留档串线(`rtClosedTranscripts` 以模型给的名字为键、
   全仓 0 处删除)是**同一种形态**。撤销只支持 scope="id" —— 允许撤销 tag
   等于任何执行体销毁时都能抹掉全站策略。

② `agent/core`:`securityDecisionFor` 分两条路径

	父(parentID == "")    → CheckTool(tool)                          // 内核自身
	子(parentID != "")    → CheckToolFor(Principal{self, "resident"},
	                                        tool)                      // 叠加父的声明

   CheckToolFor 内部**先**调 CheckTool,所以主体策略无法绕过全局黑名单。
   主体 Kind 用 "resident"(IsInternal 为真):HardPrivateTools 不拦内部委派
   (那条防的是用户主体偷看私人数据),但父声明的 Deny/Allow 仍然生效。

③ 创建点声明、销毁点撤销(生命周期对称)

	SpawnResident      → declareChildPolicy(opts.AllowedTools, opts.DenyTools)
	SpawnRTChild       → 同上
	teardownResidentWith / StopRTChild / StopRTChildren → revokeChildPolicy

   声明**必须在子开始执行之前**(rtLoop / child.Start 之前),否则存在一个
   「子已跑工具、策略还没进内核」的窗口。
   声明失败 ⇒ 回滚整个创建:一个「本该受限却无限」的执行体比创建失败危险得多。

④ 工具面:`resident_agents create` 与 `rt_children create` 新增
   `allowed_tools` / `deny_tools`。描述里写明「**你为它声明**」而非「授权」,
   因为模型很容易把白名单误读成额外授予。

## 判据(4 个变异全部实测咬住)

| 判据 | 变异 |
|---|---|
| TestParentDeclaresChildPolicy | 子退化为 CheckTool(不分主体)⇒ 红 |
| TestChildTeardownRevokesPolicy | teardown 不撤销 ⇒ 红;StopRTChildren 漏撤销 ⇒ 红 |
| TestPolicyDeclaredBeforeChildStarts | 声明挪到 rtLoop 之后 ⇒ 红 |
| TestPrincipalPolicyCanBeRevoked / TestRevokeRejectsTagScope | security 侧撤销语义 |

`TestParentDeclaresChildPolicy` 同时钉三件事:① 父声明的 deny 拦住子
② 同一条策略**不影响父**(主体隔离,不是全局策略)③ 撤销后子恢复。

`TestChildTeardownRevokesPolicy` 用源码断言单列,因为本仓反复吃亏在
「单数路径改了、复数路径漏了」—— `StopRTChild` 注销入站登记而
`StopRTChildren` 漏了,就是这么来的。真销毁需要完整记忆栈,而
「前置不满足就 skip」的判据等于没有。

## 实测(真实整机装配 + 真执行)

	① 父 ExecuteTool(cmd_run)            → 走到分派(未被拦)
	② 父声明 deny=[cmd_run] 后,子的判定  → Allowed=false
	   reason="tool \"cmd_run\" is not permitted for this principal (deny \"cmd_run\")"
	③ 父自己的判定                        → 仍 Allowed(主体隔离)
	④ 撤销后子的判定                      → 恢复 Allowed

build / vet / go test ./...(62 ok / 0 fail)/ -race(kernelmod/all +
agent/core + security)全绿;gofmt 漂移清零。

## 仍未接线

- `ScanContent`(敏感词/注入预警)仍 0 生产调用方
- 公开 SDK 未暴露 `DeclarePrincipalPolicy`(插件无法声明主体策略)
- `OversizeToolReports` / `oversizeHintFor` 仍 0 消费者
2026-10-31 06:35:32 +08:00
1d007e1d49 fix(security): 工具策略闸真的接上了 —— 根因是依赖图少一条边,不是少一行赋值
## 缺陷

生产日志实证:`[security] module enabled (31 variables)`、`[device] ready:
34 tools, security=true`,691 个日志文件里 `denied by policy` 命中 **0 次**。

`executeToolCallInner` 里的策略闸读 `a.security`,而它**永远是 nil**:

  codegraph_callers("SetSecurity") → 4 个调用方,全是 security_toolgate_test.go
  全仓唯一给 a.security 赋值的行:security_profile.go(就在 SetSecurity 体内)
  buildAgent 完全不读 k.Security;AgentConfig 里也没有 Security 字段

## 为什么判据全绿而生产是坏的

`security_toolgate_test.go` 的 4 条判据**自己调了 a.SetSecurity(...)** ——
它们把缺失的那根线补上了,所以永远绿。测的是「接线之后行为对」,
不是「生产有没有接线」。这是「机制 ≠ 接线」最纯粹的样本。

## 根因:模块边界没连上,不是少一行

`kernelmod/agent` 的 Requires() **没有声明 FacSecurity**。依赖图因此没有
理由把 security 的产物交给 agent —— 只有声明了的 device 拿到了。

即使不声明,security 也在第 1 层、agent 在第 4 层,`ks.Security` 恰好已被
填好,运行期照样能读到。**这正是它藏了整轮发布的原因**:那个"恰好"是偶然
(security 只依赖 config),不是设计。一旦 security 将来加了自己的依赖,
图不会拦,只会静默给出 nil。

所以只补 `agent.SetSecurity(k.Security)` 是错的:那会让代码读一个图不保证的
字段。必须同时改三处。

## 改动

① kernelmod/agent: Requires 加 FacSecurity(让图知道这条边)
② AgentConfig 加 Security 字段;New() 写 a.security;buildAgent 传 k.Security
③ Init 里 ks.Security == nil 时**响亮失败**(与 device 模块对 ks.Security
   的检查同型)—— 不能把 nil 一路传下去变成静默放行
④ 子 agent 继承:resident / rtchild 各自 New(AgentConfig{...}),原先从构造起
   a.security 就是 nil。它们是**最需要闸门**的执行体(驻留子长期无人盯、
   rtchild 通话中一边听一边执行工具),却恰恰完全没有闸门。

## 判据(全部通过变异验证,删掉修复必红)

新增 internal/kernelmod/all/security_wiring_test.go(钉**装配**,不是钉注入):

| 判据 | 变异 |
|---|---|
| TestAgentDeclaresSecurityDependency | 从 Requires 删 FacSecurity ⇒ 红 |
| TestAgentAssemblyWiresSecurity | buildAgent 传 nil ⇒ 红;New() 不写 a.security ⇒ 红 |
| TestSubAgentsInheritSecurity | 删 resident/rtchild 的 Security ⇒ 红 |
| TestAgentAssemblyRefusesNilSecurity | 删 Init 的 nil 检查 ⇒ 红 |

`TestAgentAssemblyWiresSecurity` 走**真实登记表装配**(真 SQLite / 真配置库 /
真 20 模块 6 层),并对一个**未注册**的工具名配黑名单后调真实 `ExecuteTool`:
闸门生效 ⇒ "not permitted";闸门失效 ⇒ "does not exist"。两者文本不同,
无从混淆,因此不需要注册探针工具。

## 实测

生产形态探针(配置写入 `core.security.tool_deny=cmd_run` 后启动真实二进制):

  [security] global policy applied: deny=[cmd_run] allow=[] sandbox=false
  Agent.ExecuteTool("cmd_run") → denied (Tool cmd_run is not permitted:
    tool "cmd_run" is blocked by security policy (denylist entry "cmd_run"))

分层未变(3+10+3+1+2+1 = 20 模块 6 层,digest 14b4f6f7),
build / vet / go test ./... / -race(kernelmod/all + agent/core + security) 全绿。

## 顺带

gofmt 漂移清零(原 1 个 rtchild_route_test.go)。
2026-10-30 23:54:08 +08:00
fbccfe17ed fix(rtchild): 同名复用串线 + 批量停止漏注销
三处缺陷,全部在「回收/复用」这条线上 —— 单实例 e2e 天然测不到:

1. RTTranscript 读序(主线)
   原实现先查留档、命中即返回。留档以 id 为键而 id 由模型给,于是
   回收后同名重建,rt_transcript 返回的永远是上一通电话的原文,
   且完全不报错。改为「在跑实例优先,无活实例才落回留档」。

2. StopRTChildren 漏注销入站登记
   单数路径会 Unregister(child/rt-<id>),复数路径没有。登记表共享、
   按 name 全局唯一,残留随 create/destroy 单调累积,而外部没有任何
   工具能单独注销一条 inputch。

3. 撤回一个错误的修法
   一度在 SpawnRTChild 里 delete 留档。变异测试证明删掉它判据仍绿
   (治不了串线),且有害:留档的意义正是「回收后父按需取原文」,
   提前删掉会造成「推了信号却拉不到正文」。现仅在 CloseRTChild 写
   同名键时自然覆写。

判据(均通过变异验证,去掉修复必红):
- internal/agent/core/rtchild_reuse_test.go(进程内,5 条)
- internal/plugins/rtchild_reuse_e2e_test.go(跨进程真插件,3 条)

★ 写判据时踩到两个坑,一并记在注释里:
  - 前两版判据假绿(先塞留档被 Spawn 清理掉 / 新实例不 push 导致
    「读到旧内容」与「读到空」都是 len==0),靠变异才发现没测到真东西
  - 等 Rounds 不等于历史已入账(rtRunOnce 先 round++ 后 rtAppendHistory),
    与 dff745c 同一个坑;已改为等 history 条数
2026-10-30 21:54:40 +08:00
bcf45498a0 feat(skill): 补 kernel-rtchild + 两条判据锁住 rt_children 静默失败
## kernel-rtchild skill

`resident_agents` 有 `resident-subagent-hygiene` skill,而
`rt_children`/`rt_transcript` 这一对整个**没有 skill** —— 模型只能靠
工具描述猜语义。

最容易踩的坑:**工具执行期不可打断**。模型一旦进入工具执行期就听不见
新输入,所以调工具前必须先用 outputch 说一句"稍等",否则用户会觉得
"它没在听我说话",而实际上它只是卡在一个 3 秒的命令里。

skill 覆盖:三种子 agent 的分界、通道必须成对且两侧声明 rt、打断语义、
只出文本(TTS 是插件的活)、窗口裁剪但原文保留、回收后用 rt_transcript
分段取、闲置零 token 但必须 stop、四条回收判据、五种常见错误对照表。

## 两条判据(都来自一次真实事故)

生产 e2e 时我用 `input="..."`(工具声明的是 `inputch`)创建 rtchild:
`strArg` 只查精确键名 ⇒ 读到空串 ⇒ **本应报"必须成对给出",但它成功了**,
还把 output 通道划给了新子。之后灌语音,该子轮次永远是 0。

症状是"通道注册了、插件活着、推送 ok、但子没反应",
与真因(参数名拼错)隔了三层。

- `WrongParamNameIsRejectedNotSilent`:写错必须失败、必须指名正确参数名、
  必须无副作用(不建子、不动通道归属)
- `PairAssignmentIsAtomic`:成对分配第二个失败时第一个必须回滚
  (原来那句 `rollback()` 没有判据覆盖,是死代码)

变异验证:让代码对 `input`/`output` 静默兜底 ⇒ 第一条变红
(`参数名写错却创建成功了:[typo-child] —— 这会得到一个收不到输入的哑子`)。

go build/vet/gofmt 干净;go test ./... -count=1 -race = 62 ok / 0 FAIL / 0 RACE
2026-10-30 18:09:50 +08:00
dff745cde2 test(rtchild): rt_children stop 判据改为等历史入账,不等 Rounds
判据偶发红(实测约 1/12):`stop 没有告诉模型用 rt_transcript 取回原文`。

根因是**判据自己等早了**,不是产品缺陷:

- `Rounds` 在 ChatStream 被调用时就自增(请求刚构建完)
- assistant 那条消息是**流结束后**才追加进 history 的

于是 stop 可能读到 n==0,走"本次通话没有上下文需要交接"分支 ——
而那个分支**本来就该**不提 rt_transcript(真没内容可取)。

改为等 RTTranscript 非空。

★ 这是同一类坑的第二次复发(第一次在 rtchild_r7b_test):
判据等"请求已发出"而不是"结果已落库"。
"""
2026-10-30 17:21:51 +08:00
eb1deab5dd feat(agent): rtchild 实时子 agent + 四类 agent 独立模型源 + 残余任务四态
## rtchild(R1–R13)

第三种、**独立**的子 agent 类型,面向语音/视频通话:内核只要文本流,
不定音频(TTS/播放由插件负责)。

- R1  CapRT 能力位 + AgentConfig.IsRealtime;AssignRealtime 两侧校验
- R2  rtSlot 单槽覆盖(latest-wins,从不排队)
- R3  独立 ctx + rtLoop + SpawnRTChild/StopRTChild
- R4  ChatStream 逐块 → outputch(rt).Execute(text)
- R5  打断 = 原地截断,不重试
- R6  工具执行期不可打断(suppressedCancels 让守卫可观测)
- R7  普通上下文 + 窗口裁剪(旧内容不进模型,**原文保留**)
- R9  CloseRTChild → L4 信号 + rt_transcript 按需分页取
- R10 notify_parent(L4,注册一次,停即注销)
- R11 闲置零 token
- R13 rt_children 管理工具(父专属)

刻意**不做** rtPage/anchor/degraded_resets(过设计,已裁定);
不用四级中断阶梯(preemptCooldown+同级不抢占会让 ~75% 的 rt 打断
静默退化为排队)。

## 四类 agent 各自可配模型源

core.agent.llm.{main,resident,rt,child}_provider,语义是**优先不是独占**:
该源排到候选链最前,其余源仍作回退。硬锁定会让"首选源抖一下"变成
"这个 agent 彻底不工作",而多源回退链正是本仓为此建立的机制。
名字写错 ⇒ 回退默认源 + 记日志(不静默)。子 agent 未配置时继承父的。

★ 顺带修掉一个真缺陷:spawn.go 原来直接 a.provider.Chat(),
**普通子 agent 完全绕过 provider 回退链** —— 它是唯一绕过的地方:
默认源抖一次整条子任务就"执行失败",而主 agent 同刻能自动换源。

## 驻留子残余任务四态 + 通道三态

残余任务:keep / takeover(接手,keep 的明确别名)/ stash(暂存到各自
原通道)/ drop。stash 必须**逐条按各自 OutputChannel 归位** —— 一个子
可能同时收 qq 和 webui,混存会让"接回 qq 的人"拿到本该给 webui 的活。

通道:release(零值,向后兼容)/ suspend(挂起,暂存保留)/ takeover
(接回父并立即释放暂存)。原来是无条件 release,于是只有两种坏结局:
通道一直没人分配(消息无人处理且无提示),或刚好被别的 agent 抢到。

一次定完不留窗口:FinishResidentWith → DestroyResidentWith →
teardownResidentWith。任一环退回不带 disp 的旧函数,disp 就被丢掉、
通道被无条件释放 —— 而 stash 的暂存正挂在那条通道上(实际踩过)。

## 修复"手写字段白名单"丢值(7 处)

同一类缺陷:不传整个结构体,而是手写一份"允许通过的字段清单"。
危险在于**写漏了不报错** —— 字段静默归零,故障出现在很远的地方。

- corehandler.go outputRegister: ChannelDef{NoMemory,Cleaner} → 整体传
- pubSdkInjectOpts: 逐字段摊平丢 ScenePolicy/SecurityProfile → 收整个 injectParams
- injectParams/injectMediaParams: 结构本身缺字段 → 补齐 + asInjectParams()
- 配套反射判据 TestSegment_RoundTripEveryField(取代手写断言,
  原判据漏了 Phase/LLMText/FinalText/ToolCalls/ExtraKeyMediaBlocks/ExtraBag)

## 压缩偏离设计

CompressResident 原为截断,与设计(LLM 摘要替换)不符。改走
PlanCompaction → summarizeForCompaction(用子自己的 provider)→
ApplyCompaction,失败回退 TrimKeepRecent。顺带修 ApplyCompaction 的
锁重入 panic 与双重解锁。

并修一处真实生产数据竞争:ctxFullSignaled 由父 goroutine 写、
子调度 goroutine 读,加 ctxFullMu。

## 判据

新增约 70 条。全部验证过能变红(变异测试),包括:
- TestSegment_RoundTripEveryField 的防腐蚀:fullStageContext 反射断言
  每个字段非零,否则新字段永远不会被覆盖("假绿")
- TestPreferredProvider_PutsSourceFirstAndKeepsFallbacks 成对断言
  "在首位且回退仍在"—— 只断言前者的话,硬锁定也会绿

## 判据自身的坑(两次偶发红,都不是产品缺陷)

1. SpawnResident 末尾 Start(),子会在两次 enqueue 之间取走一条
   → "暂存 0 条"。修法:cancel + waitSchedulerStopped。
   (offload_test.go 早有记录,我没先读就重踩了一遍)
2. TaskPrompt 异步投递,可能在清空队列之后才到 → "多 1 条"。
   靠探针打印内容才认出是 TaskPrompt。修法:本文件用 spawnQuietResident。

go build/vet/gofmt 干净;go test ./... -count=1 -race = 62 包 ok / 0 FAIL / 0 RACE
2026-10-30 17:00:47 +08:00
b850feaef2 feat: 段页式记忆流转、coldcontext 重命名与安全治理
记忆层:
- MemoryBlock 承载 FrameTS/PageSeq/PageKind/BlockRole/CallID
- coldcontext 归档与回填按完整页传递,工具调用参数和配对键不丢失
- 召回先估算总 token,超预算时按全局页相似度标记 inactive
- inactive 仅是 context 渲染期状态,不落盘、不进入 coldcontext,Prune 后重置
- Peek/确认/Remove 两阶段召回,失败保留冷上下文副本
- stablemem 记录 last_recalled_at,删块连带清理边

命名:
- internal/memory/document → internal/memory/coldcontext
- Doc/DocEvent/DocHit → ColdDoc/ColdEvent/ColdHit
- ContextToColdContext/RecentColdContexts/FindColdContexts
- 保留既有 JSON 标签、doc_ 文件前缀与 doc_count 状态键,兼容存量数据

安全治理:
- 工具策略门禁、安全配置、事件总线发布者身份与入站脱敏
- 声明式 SecurityAPI 与 QQ owner/guest profile
- 日志保留策略与相关 e2e/回归判据

验证:go build ./...、go vet ./...、go test ./...(62 包);
生产副本 e2e:7013 块迁移后 ID/向量/指纹 0 变化、0 缺失。
2026-10-29 23:53:48 +08:00
a3e50b395f merge: 差分 OTA + 安装形态分派 + 事件总线统一受安全监控 (#32)
feat(update): 差分 OTA + 安装形态分派 + 事件总线统一受安全监控
2026-10-29 10:01:45 +08:00
ba281a855a feat(update): 差分 OTA + 安装形态分派 + 事件总线统一受安全监控
三条主线,由 e2e 实测驱动。

## 一、差分 OTA(internal/update/delta.go)

实测:改一行逻辑,88MB 的二进制逐字节只差 80 字节,而 OTA 一直下整包。

用 rsync 族的滚动哈希块匹配(4KB 粒度),不是 bsdiff —— 后者需 17×N
峰值内存(88MB → 1.5GB),而升级恰可能发生在内存紧张时。

真实二进制实测:88,036,728 字节 → 补丁 77,316 字节(0.088%)。

三道闸:应用前验旧文件 sha256(错基线会静默产出垃圾)、应用后验结果
sha256、COPY/LITERAL 越界防护。任何失败**退回整包**,不向上抛 ——
差分是优化,不是可用性前提。

接入:Source.Resolve 优先试 delta;CheckRelease 按 -from-<commit8>.delta
匹配(不猜基线);CLI 新增 -make-delta;打包脚本 build_delta(需
PREVIOUS_HOMED),delta 进 SHA256SUMS。

## 二、安装形态分派(internal/update/install.go + channel.go)

内核对「能不能自己升级」此前只有一个答案:直接替换二进制。而 deb/rpm
形态下那样做会让 dpkg/rpm 数据库与实际内容不一致(下次 apt upgrade
覆盖回旧版,或 dpkg 拒绝后续升级)。

新增探测:deb/rpm/tar/container/source 五类形态 + 变体(full/server/
client/toolchain)+ 目录写权限。判据全部来自可观测系统事实(dpkg 的
info 目录、容器标记文件),不做路径推断。

三端统一走 PlanUpgrade 分派;形态闸门放在**下载 880MB 之前**。

## 三、事件总线统一受安全监控(internal/events + security)

用户指正:「eventbus 上所有事件都要接受安全模块的监控」。

此前 EventPolicy 只被 internal/sdk/events.go 调用,而那是四条路径中的
一条。s.Publish / s.Subscribe / s.EventBus() 拿的是裸 *events.Bus,
任何插件都能绕过策略发任意事件(含伪造内核事件)。

现在闸门在 Bus.Publish/Subscribe 本体上(Guard 接口注入,避免 events
↔ security 成环),四条路径全部覆盖。sdk.EventBus() 改返回带身份的
BusHandle。内核身份免授权,但载荷照样过凭据闸门。

白名单按主体收窄:agent_output/stage/terminal_output 只允许承载者
(webui/cli/agentcli)发布 —— 它们是内核向外的输出面,谁都能发就等于
能伪造 agent 说的话。

## e2e 暴露并修掉的三个真实回归

	[bus] subscribe denied: pluginmgr type=update_request
	[bus] subscribe denied: skillmgr type=skill_detected

前两个让升级请求静默丢失、职责链交接断掉。内部协作事件必须可订阅
(安全边界在发布侧,订阅只是「听得到」)。

## 顺带的止血修复

- 回滚判据从 `--version` 改为 digest:旧内核(1.3.x 及以前)没有该
  flag,而它恰是最常见的回滚目标 —— 原逻辑让「从旧版升级失败」永久
  无法回滚。
- CopyFile 补 fsync(替换 homed 本体时掉电会留半截文件)。
- restore 完整还原才清 marker(原先先清再还原,还原失败就失去第二次
  自愈机会)。
- 回滚时移除升级中新增的 depgraph(否则「旧 homed + 新 depgraph」
  会因 digest 不匹配崩溃循环)。
- homed-upgrade 不再硬编码 systemctl restart(丢 guard 状态、unit 名
  漂移);改用内核已有的 restartCh → exit 42 语义。
- homed-upgrade 不再用 CGO_ENABLED=0 编却依赖 go-sqlite3(stub 下
  静默失效,该路径从未工作过);改用 internal/config。
- 服务单元补 User=hologramagent + 三条安装路径补建用户(此前发行物里
  完全没有落实降权)。
- rpm 的 fpm 调用补 --after-install(此前 rpm 装完根本不建用户)。

## 判据

新增 30 条:差分 8(往返/压缩比/错基线/截断/篡改/边界)、通道 8、
执行器 6、事件闸门 8、按主体授权 8。全仓 go test ./... 0 失败。
2026-10-29 09:54:55 +08:00
86d1f4c547 test(update): 补 TestMarkPendingUpgrade_PreservesOldMarkerOnFailure(判据 B2c)
## 为什么还要再加一条

前两条 commit 之后,B2 的「原子写」在判据侧其实是哑的 —— 我复核时实测
过:把 MarkPendingUpgrade 的 `tmp := p + ".tmp"` 改成 `tmp := p`
(退回最初的原地覆写 bug),以及把整个函数退回 os.WriteFile,
`TestMarkPendingUpgrade_IsAtomic` **都照样绿**。

因为它断言的是「最终状态对」:不留 .tmp 残留、内容可解析、可覆写。
os.WriteFile 这三条全都满足。也就是说那条用例证明的是「结果正确」,
没证明「写的过程不会毁掉已有数据」—— 而后者才是 B2 要防的事故。

## 这条用例钉什么

用它逼出两者唯一可观测的差异:**失败路径上旧文件还在不在**。

  ① 先写一个完好的旧 marker(FromVer=1.0.0)
  ② 把 pendingPath+".tmp" 占成**目录** ⇒ OpenFile 必 EISDIR
     (稳定可复现,不依赖权限/磁盘/平台;失败点与「磁盘满/掉电」同类:
      已打开目标、还没写完全部内容)
  ③ MarkPendingUpgrade 必须返回 error(不能假装标记已就绪)
  ④ 旧 marker 必须原样保留:FromVer 仍为 1.0.0、ToVer 仍为 1.1.0

③④ 都用 Error 而非 Fatal:让两个症状一起报出来。

## 语义差异

	原子写(现状):新内容写进 .tmp 时失败 ⇒ 目标文件仍是完整旧版本
	原地覆写      :O_TRUNC 先清空再写,中途失败 ⇒ 目标文件被截断,
	                旧 marker 永久丢失 ⇒ 不知道上次升了什么,也没了回滚依据

## 变异(已实测)

	B2c-M1  rollback.go  `tmp := p + ".tmp"` → `tmp := p`   ⇒ RED
	        实际输出:旧 marker 被破坏了:FromVer 应为 1.0.0,实际 "2.0.0"

零用例假绿也堵住了:判据脚本对 `no tests to run` 直接 FATAL。

仅动 internal/update/kernel_apply_test.go(测试文件),生产代码零改动。
2026-10-28 17:56:44 +08:00
1319d6619c fix(update): dataDir 取内核权威源 + 回滚可信校验 + marker 原子写
承上一个 commit(回退 85fd38b)。本 commit 是 R2 主体:B0/B1/B2 三项修复,
以及为 P1–P4 补齐的**行为级**判据。

━━━ B0 · dataDir 不再推导,取内核真实 -data ━━━

`internal/update/kernel.go`
  · 删除 ResolveDataDir(env → `<exe>/data` 兜底)。
  · 新增 SetKernelDataDir / KernelDataDir:值由内核适配层在启动时
    用 ks.DataDir 注入。进程内调用方(pluginmgr / webui)直接取,
    不做任何推导;取不到返回**空串**。
  · DataDirEnv 保留但降级为诊断参考(不再是合法来源)。

`internal/kernelmod/update/mod.go`
  · Init 里 upd.SetKernelDataDir(ks.DataDir);为空时打响亮告警
    (不阻断启动 —— 第 0 层职责是回滚判定,不是参数校验)。

`internal/plugins/pluginmgr/kernel_update.go`、`internal/plugins/webui/kernel_update.go`
  · kernelDataDir / dataDirOf 都改为 return update.KernelDataDir()。

`cmd/homed-upgrade/main.go`
  · upgradeDataDir(explicit):① `-data <dir>`;② config.db 的
    core.daemon.data_dir(只读连接 + PRAGMA query_only=1,不建表不迁移)。
  · 两者都拿不到 ⇒ 返回空串 ⇒ `-apply` 在动手之前就 fail 并说明怎么修。

→ 这样 P1 的空值守卫重新变成**活的**:拿不到真实 dataDir 就真的会拦。

━━━ B1 · 回滚可信:回读校验是唯一判据(判据 B1)━━━

`internal/update/rollback.go` — RollbackIfUnconfirmed
  · rollback.go:315 `rb, verr := readBinVersionStrict(binPath)`
    替换后**必须**回读成功且 rb.Version == rec.FromVer,否则
    rolled=false + error + **不清 marker**(下次启动再试,并让人看到日志)。
    CopyFile 返回 nil 只代表字节搬完了,不代表搬过去的东西能执行。
  · rollback.go:254 确认分支同样改用 readBinVersionStrict。
    这里曾经用 ReadBinVersion —— 它含 InferVersionFromRunning 回退,
    已实测:binPath 是不可执行残文件、marker.ToVer 恰好等于本机 waiter
    版本时,会从 waiter **借到**版本号 ⇒ confirmed=true ⇒ 清 marker、
    不回滚 ⇒ 机器带着跑不起来的二进制继续启动且失去自愈机会。
    该缺陷环境相关(CI 没装 homed/waiter 会绿),故判据不能依赖本机环境。
  · 备份缺失 / 回读失败 / 版本不符 / depgraph 回滚失败 —— 四种情形
    一律不清 marker。
  · 新增 readBinVersionStrict(kernel.go):只认「这个文件自己能报出版本」,
    无任何回退;ReadBinVersion 改为其包装器(保留探测用回退),
    注释写明它**不能**用作回滚校验。

━━━ B2 · marker 原子 + 损坏不卡启动(判据 B2)━━━

`internal/update/rollback.go` — MarkPendingUpgrade
  · 旧实现 os.WriteFile 直接覆写(O_TRUNC),掉电留半截 JSON。
  · 现改为:写 <path>.tmp → fsync(文件) → rename(原子) → fsync(目录)。
    目录 fsync 失败仅告警(容器/tmpfs 下 EINVAL 不该阻断升级)。
    rollback.go:112 `if err := f.Sync(); err != nil {` 即这条链路的第一道闸。
  · RollbackIfUnconfirmed 遇到解析不了的 marker:quarantineMarker 改名
    留存(不删,保留现场)+ 告警 + return false,"","",nil
    ⇒ 当作「无待确认升级」继续启动。把内核卡在启动阶段是最差选择:
    能执行到这行已证明当前二进制可执行。

`internal/kernelmod/update/mod.go`
  · 补注释钉住语义:rerr != nil 必须中止启动;但 marker **损坏**不算
    失败(已由 RollbackIfUnconfirmed 吞掉并改名留存)。

━━━ 判据补齐(行为级,修掉 M6 真空洞)━━━

`internal/update/kernel_apply_test.go`
  · P1 TestApplyRefusesEmptyDataDir 加强:切到 t.TempDir() 并拍快照,
    断言拒绝升级时**根本没写任何文件**(旧用例只查「目标二进制没变」,
    把守卫挪到预检之后依旧绿 —— 因为 filepath.Join("", prefix) 会把
    备份目录建到 CWD)。
  · P3 新增 TestApplyPrunesOldBackups:预置 5 个历史备份后跑真实 Apply,
    断言旧的被 GC。★ 这是补 M6 的真空洞 —— 旧的变异脚本只跑
    `-run TestPruneOldBackups`(只证明函数能跑,3 毫秒),把 Apply 里
    那一行 PruneOldBackups(dataDir) 删掉它照样绿;新脚本必须同时跑
    TestApplyPrunesOldBackups,删掉调用后立刻报红。
    「函数能跑」≠「Apply 真的调了它」。
  · B1 新增 TestRollback_RefusesCorruptBackup(零字节/坏备份 ⇒ 必须
    回滚失败且不清 marker)、TestRollback_RefusesVersionMismatch
    (备份版本 ≠ FromVer ⇒ 同样失败)、
    TestRollback_ConfirmedBranchIgnoresInferredVersion 与
    TestConfirmedBranch_StrictProbeBitesOnUnparseable(确认分支不得
    使用推断值;用可注入的 inferHomedPath/inferWaiterPath 造出
    「能借到版本」的场景,不依赖本机是否装了 homed/waiter)。
  · B2 新增 TestMarkPendingUpgrade_IsAtomic、
    TestRollback_CorruptMarkerDoesNotBlockBoot。

`internal/update/rollback_test.go`
  · TestRollback_RestoresBackup / TestRollback_RollsBackWhenRunningOldVersion
    的备份从纯字节 "OLD-GOOD" 改为**真正可执行**的假二进制(1.4.0),
    并按版本而非按字节断言。旧的写法只能测「字节搬对了」,测不到
    「搬过去的东西能跑」—— 而后者才是回滚能救机器的前提。

`internal/update/kernel.go`
  · inferHomedPath / inferWaiterPath 提为包级变量(生产默认值不变),
    唯一目的是让「确认分支不得使用推断值」这条判据可稳定复现。

━━━ 判据锚点自检(grep -F 命中数均为 1,行号已核对)━━━

	P1  kernel.go:348     if dataDir == "" {
	P2  kernel.go:370     backup := filepath.Join(dataDir, BackupDirPrefix+ts)
	P3  kernel.go:449     PruneOldBackups(dataDir)
	P4  kernel.go:445     publishResult(bus, "kernel", "", true, …)
	P4  events.go:69      "error":       errMsg,
	B1  rollback.go:254   if cur, cerr := readBinVersionStrict(binPath); cerr == nil {
	B1  rollback.go:315   rb, verr := readBinVersionStrict(binPath)
	B2  rollback.go:112   if err := f.Sync(); err != nil {

验证:go build ./... 通过;go test ./internal/update/ 通过。
2026-10-28 17:51:52 +08:00