Commit Graph

739 Commits

Author SHA1 Message Date
e20c177e88 chore(site): 介绍站同步 1.4.0,并迁移 gitcode → GitHub 链接
1) 链接迁移:33 处 gitcode.com → GitHub(20 处 SDK、13 处主仓),0 残留。

2) 补齐 1.4.0 特性:「真正的 AgentOS」四张卡(隔离/调度/通信/资源)之外,
   新增第五张「并行 —— 同轮的工具,一起跑」,把声明式并发安全与
   整批降级这两条关键语义写清楚(这是当前版本最重要的架构变化,
   而站点此前一个字都没提)。

3) 插件列表与实际 SDK 示例对齐:补 ai_image(v1.3.0) / files(v1.0.0) /
   luademo(v0.1.0) 三张卡与对应徽章(23 项)。版本与描述取自各自 plg.json。
   注意 mc / homeagent-mail-bridge **不是**过期项 —— 它们是真实插件,
   只是源码不在 SDK 仓,各有自己的链接。

已用共享浏览器(CDP)在 file:// 下实测:徽章 23 个、点 ai_image 能正确
切换出卡片(可见性检查通过)、第五张卡渲染正常、页面内 gitcode 残留为 0。
2026-09-29 17:09:02 +08:00
16379f9cb2 ci(pages): 介绍站的 GitHub Pages 镜像
主站仍自托管在 NAS(192.168.2.106),国内内网直连毫秒级;GitHub Pages 提供
海外可达性与灾备。两边同源(同一个 site/ 目录),不存在谁是「真身」。

发布前的完整性检查是必要的:Pages 是静态托管的,**缺文件不会让部署失败**
(照样 200 + 404 页),所以「引用存在但文件没提交」这类问题必须在这里拦住 ——
检查 index.html 引用的每个 assets/ 资源都实际存在。

三个 action 版本已逐个查 GitHub API 确认真实存在(checkout@v7 与仓库其余
workflow 保持一致;configure-pages@v5 / upload-pages-artifact@v4 / deploy-pages@v4)。
workflow 已过 actionlint,检查逻辑已在本机原样跑通(3 个文件、165778 字节、
引用清单 assets/logo.svg)。

注:Pages 需在仓库设置里把 Source 选为「GitHub Actions」后本流水线才会真正发布。
2026-09-29 17:09:02 +08:00
2aa6fe6b57 docs: README 对齐 1.4.0 现状,并移除看板娘
四处过时事实:
  1. 「v1.3.x 线(v1.3.1–v1.3.12,最新已发布)」→ 补上 v1.3.13,并按 main 的实际内容
     新增 v1.4.x 段落(同轮工具并行、seq 序列编排、结果只统计不裁剪、结构化错误契约、
     设备命令白名单、GUI 增量渲染)。这些是 MAIN 上已合入但 README 从未提及的特性。
  2. 「内置 18 个插件」×2 → 实际 20 个(漏了 seq / kbtree 等)。
  3. 6 处 gitcode 链接 → GitHub(仓库已迁至 github.com/JianFeeeee/HomeAgent,
     SDK 为 github.com/JianFeeeee/homeagentsdk)。gitcode 仍作国内镜像保留。
  4. 移除「看板娘 / Web Mascot」整段(中英文各一处)—— 按用户要求。

三张架构图按代码重画(此前图上缺了 1.4.0 的核心机制):
  - 图一「消息处理时序」:tool 分支改为**批**语义 —— batchRunnable 三条判据
    (批内 >1、全部 ParallelSafe、无同通道重复发送)、可并发/整批降级两条路、
    以及「按声明序收尾 + 同通道保序」。
  - 图二「Stage 管道」:④⑤ 拆成并发/串行两条边,并注明并行只影响执行时序,
    post_action 与 after_toolcall 的可见顺序不变。
  - 图三「三层记忆」:补上**场面识别(声明 + 涌现)**子系统 —— 指纹权重、
    归属/唤起阈值、origin=declared|emergent、用进废退衰减。

另加一段说明:agent 自己的回复也写回记忆(context.Append(Source:"agent")),
因此它能读到自己先前的结论并主动纠正 —— 这是记忆召回的自然结果,
内核里**没有**任何名为「反思」的机制(避免把涌现行为误读成已实现的特性)。
2026-09-29 17:09:02 +08:00
8da670ca14 chore: 用 .mailmap 归并虚拟贡献者身份
GitHub 的 Contributors 列表按**提交邮箱**归并身份,而本项目历史里同一个人的提交来自
多个邮箱(本地 root、个人 QQ、gitcode noreply、GitHub noreply、dev@local 等),
于是列表里冒出 5 个并不存在的「协作者」:
  root@qq.com(135) / root@qq.com(51) / 2198972886@qq.com(33+4)
  dev@local(14) / root@minecraft-server(10) / 109188060+JianFeeeee@...noreply(19)
加上真实身份,7 条里只有 1 条是人。

.mailmap 只影响**展示层**(git shortlog / git log --use-mailmap / GitHub Contributors),
不改写任何提交对象 —— 历史 SHA 全部不变。归并后:
  git log --use-mailmap → JianFeeeee(715) + HomeAgent Agent(19)

HomeAgent Agent <agent@homeagent.local> **刻意不归并**:agent 的自动提交保留独立身份,
便于区分人类提交与自动化提交。
2026-09-29 17:09:02 +08:00
6d6beed72a chore(watch): 下线站点漂移巡检脚本
用户指示「那个脚本直接去了吧」:site-drift-watch.sh 每半小时巡检一次两个文档站
的产物漂移,但实测 5 次告警**全是源码漂移误报**(判据只比时间戳,而产物生成后的
提交都不影响文档站),线上漂移与探活失败从未报过 —— 信噪比太差,去掉损失很小。

调用方核查:只有 root crontab 的 `7,37 * * * *` 一条,systemd 无 unit,
脚本/Makefile/.github 里零引用。crontab 已按行备份并只删该两行(其余三条任务保留),
脚本本体备份在 /root/backups/ 可随时还原。

本提交只删仓库内的脚本文件;crontab 与运行环境侧的调整已在部署时完成。
2026-09-29 17:09:02 +08:00
f339850ffb docs(ci): CI/CD 流水线手册 —— 用法、机制与 6 个踩过的坑
仓库迁 GitHub 后新增两条流水线(ci.yml / release.yml),但用法与机制
此前只存在于 workflow 的注释和提交信息里。发版是高频操作,写成手册。

内容:
- §1 CI 六个 job 与「明确不进 CI」的清单(真机/密钥/内网依赖)
- §2 发版标准流程、幂等闸门(tag 存在即跳过)、
     发版门(go build 硬门 + go test 可用 [skip-release-tests] 跳过)
- §2.5 构建资产(ci-assets-v1)的托管与升级方式
- §3 六个实测踩过的坑:
      3.1 workflow 文件必须存在于目标分支(否则推 release/** 不触发)
      3.2 runner 无 electron 缓存 ⇒ 必须 npm ci(且不能用 --production)
      3.3 管道里的 grep -q 因 SIGPIPE 误杀检测(800M 包必炸)
      3.4 gh 在非 git 目录要显式 --repo
      3.5 gitcode 上传的 ASSET_DIR + 裸名语义
      3.6 手工补发 gitcode 附件的流程
- §4 SDK 仓的差异(独立 module 测试要跑两处、CGO 不需要)
- §5 关于失败邮件的说明(可能是验证步骤自身 bug 的假警报)

markdownlint 全绿(两个产物清单代码块补了 text 语言标注)。
2026-09-29 15:03:05 +08:00
192e63cf52 ci(release): gh release download 需显式 --repo(非 git 目录无法推断)
## 症状(第三次试发布)

Build 全绿、tag 已建、release 已建、2.40GB 附件全部上传成功 ——
唯独最后一道「回读校验」失败,整个 workflow 因此标记为 failure:

    failed to run git: fatal: not a git repository (or any of the
    parent directories): .git
    ##[error]Process completed with exit code 1.

## 根因

回读校验为了"下一份干净副本"先 `cd /tmp/back`,那里不是 git 仓库。
而 `gh release download` 默认从**当前目录的 git 上下文**推断仓库与
host(GITHUB_REPOSITORY / GH_HOST 之类环境变量不足以让它跳过推断),
于是报 "not a git repository"。

## 修法

    gh release download "$TAG" --repo "$GITHUB_REPOSITORY"

两个仓的 workflow 都有同一处(主仓 + SDK),一并修。

## 顺带

`third_party/homeagent-sdk/.github/` 加入 .gitignore —— 与 skills/ 同类:
SDK 仓自己的 workflow 由 SDK 仓跟踪管理(那边已跟踪),本仓不参与
构建,不需要在这边重复一份。未加规则时它会出现在本仓的未跟踪列表里。
2026-09-29 14:39:08 +08:00
91fc5093d1 ci(release): 验证产物改用 >/dev/null 而非 grep -q —— SIGPIPE 误杀检测
## 问题(第二次试发布实测)

打包成功后,「验证产物」步骤失败:

    tar: stdout: write error
    dpkg-deb: error: tar subprocess returned error exit status 2

而三个 deb 的元数据其实已全部正确打印(Package/Version/Architecture)。

## 根因

`dpkg-deb -c <800M 的 full 包> | grep -q <模型文件>`:

grep -q 匹配到目标行后**立即退出**、关闭管道读端 ⇒ dpkg-deb 内部的
tar 继续写 stdout 时收到 EPIPE ⇒ pipefail 判整条 pipeline 失败。

⇒ 检测项本身是好的(模型确实在包里),却被检测手段误杀。

本地用 CI 上同一个 800M full 包复现:
    grep -q 版    → dpkg-deb: error: tar subprocess was killed by
                    signal (Broken pipe)
    >/dev/null 版 → 通过

client(80M)没炸、full(800M)炸 —— 包越大越容易触发(内容越多,
grep -q 提前退出的窗口越大)。这正是它没在本地小规模测试里暴露的原因。

## 改法

检测存在性时用 `grep <pattern> >/dev/null`(读完整个输入再退出),
不用 `grep -q`。顺带补了 server 包的模型在位检测(原来只测了 full)。
2026-09-29 14:19:29 +08:00
5afe8be432 ci(release): 修 gitcode 同步的路径拼法(原写法必然找不到文件)
upload_assets.py 的路径语义是 `os.path.join(ASSET_DIR, name)`,
而原写法先 `cd dist` 再传 `./*.deb` ⇒ 拼成 `dist/dist/...`,
必然 "资产目录不存在" 或逐个 skip。token 一配上就会炸,属隐患。

改为:cd 进资产目录 + `ASSET_DIR=.` + **不传文件名**(让它扫描当前目录,
.deb/.tar.gz/SHA256SUMS 都在它的产物白名单里)。

SDK 侧另有一处同类问题,但那里**必须显式列名** —— hmapdev 的产物多数
没有扩展名(只有 windows 那个是 .exe),自动扫描会静默地一个都不传。
已同步修在 SDK 仓的 workflow 里。
2026-09-29 14:09:19 +08:00
87f8fc1485 ci(release): [skip-release-tests] 标记改查发版提交,不再查 HEAD
原实现用 `git log -1 --pretty=%B`(即 HEAD)找标记。但发版提交之后
往往还会跟几个提交(同步 workflow、改文档、修脚本),HEAD 一移动,
标记就被顶掉 —— 跳过机制**静默失效**,流水线又回去撞旧线的红测试。

改为查**改动 internal/meta/meta.go 的那个提交**(语义上正是「发版提交」):
  REL_COMMIT=$(git log -1 --format=%H -- internal/meta/meta.go)

顺带把 grep -qF 换成 case 匹配,避免多层引号嵌套。

自测(本地 worktree):
  发版提交 472d908 → 命中标记 ✓
  HEAD      fa5b1b4 → 不命中(旧逻辑会在此静默失效)

actionlint 全绿。
2026-09-29 14:03:25 +08:00
1acc933dc8 ci(release): 打包前装 Electron,否则 GUI 被静默跳过
## 问题

打包脚本从两处找 Electron 运行时:
  1) ~/.cache/electron 里的 electron-v<ver>-linux-<arch>.zip
  2) cmd/gui/node_modules/electron/dist(目标架构 == host 时)

全新 GitHub runner **两处都没有** —— 脚本在都没有时只能跳过 GUI,
于是 client/full 包会**静默地不含界面**(正是脚本作者担心的「假包」)。
实测:本地移走缓存后 GUI 被跳过,包仍能产出。

## 改法

打包前在 cmd/gui 执行 `npm ci`,让 electron 落到 node_modules。
runner 是 amd64 == 目标架构,脚本便走第 2 条路径。

**用 npm ci 而不是 `npm install electron@<range>`**:后者是非确定性的
(range 会随上游漂移,也锁不住传递依赖),zizmor 也把它标为
adhoc-packages 风险。package-lock.json(lockfileVersion 3)已锁定
electron,ci 严格按 lock 安装 ⇒ 同一 commit 永远得到同一套依赖。

不能用 `npm install --production`:那会跳过 devDependencies,
而 electron 正是 devDependency(这正是脚本自己那条命令找不到它的原因)。

## 验证

- 无声 cache + 有 node_modules/electron 时,脚本走 host-arch 回退并
  成功构建 GUI(263M / x86-64)—— 已本地实测
- 两者都无时改为打印明确原因并跳过(配合上一个 commit 的 find 修复)
- npm ci --dry-run 通过;actionlint 全绿
2026-09-29 14:02:18 +08:00
ac087aedb7 fix(packaging): find 在 set -euo pipefail 下致命退出 —— 任何无 electron 缓存的机器都打不出包
## 症状

CI 发布在「打包」步骤失败,输出停在:

    >>> Building GUI directory for linux/amd64...
      npm install...
      electron 版本取自 package.json 依赖声明: 33.0.0(非精确)
    >>> Restoring original go.mod...
    ##[error]Process completed with exit code 1.

没有错误信息,看不出真因。build_go 之前已全部成功(homed 80M 带 onnxruntime)。

## 根因(本地精确复现 + bash -x 追踪)

    + zip=$(find "$HOME/.cache/electron" -name "electron-v33.0.0-linux-x64.zip" | head -1)
    + zip=
    + restore_all          ← 直接退出

脚本是 `set -euo pipefail`。`find` 对**不存在的目录**返回退出码 1,
pipefail 让 pipeline 返回该 1,而 `set -e` 对**赋值语句里的命令替换**同样生效
⇒ 整个脚本当场退出。

实测退出码对照:
    x=$(find /不存在 | head -1)               → 1(脚本死)
    x=$(find /不存在 | head -1 || true)       → 0(存活)
    x=$(find /不存在)                          → 1(无管道也死)

⇒ 任何**没有 ~/.cache/electron 的机器**(全新克隆、CI runner、其他开发机)
都会撞上。本机一直「能打包」只是碰巧有那份 646M 缓存。

## 修法

给 4 处 find 加 `|| true` 兜底(214/217 electron 缓存、623/625 rpm_deb)。
另修 206 行 `[ -n "$ever" ] && echo ...`:`ever` 为空时该列表返回 1,
在 set -e 下同样会杀死脚本 —— 改为 if 形式。

★ 注意 608 行(fpm 查找)**早就有** `|| true`,说明这个模式被意识到过,
只是漏了这几处。属同一类缺陷的补全,不是新引入的写法。

## 验证

- 复现:移走 ~/.cache/electron 后 `package-linux.sh amd64 build` → 修复前 exit=1
  (输出与 CI 逐行一致),修复后 exit=0 且打印明确原因:
  `WARNING: electron binary not found at ... GUI will be skipped.`
- 有 electron 时仍正常构建:GUI 263M / x86-64(走 host-arch 回退路径)
- bash -n 与 shellcheck -S error 均通过
2026-09-29 13:59:47 +08:00
9beb3b558e ci(release): go test 门可显式跳过(默认仍严格)
## 问题(试发布实测暴露)

把 CI/Release 带到 release/v1.3.x 后,CI 三个 job 全红,但**每个失败都是
该分支自身的旧状态,与改动无关**:

| job | 失败原因 | main 上 |
|---|---|---|
| GUI (node) | `npm error Missing script: "test"`(1.3.x 尚无该脚本)| 正常 |
| Go test | TestRealPlugin_DeepSearchKeepsSharedBackendOnStop | **通过** |
| C gates | exit 2(1.3.x 无 csrc 基础设施)| 正常 |

⇒ 给已存在的发布线补新流水线 = 用今天的门去量旧代码。硬门会让该历史
维护线**完全无法发版**,正是用户要的「推 rel 分支就出产物」被挡死。

## 改法

拆开两道门,语义不同:

- `go build ./...` —— **硬门**,不可跳过。产物不可能建立在编译失败的代码上。
- `go test ./...` —— 默认跑,但可跳过。两个来源:
  1. workflow_dispatch 的 `skip_tests` 输入
  2. **发版 commit 里写 `[skip-release-tests]`**

第二个来源是关键:决定落在**定义该次发版的那个 commit** 里,`git log` 可审计,
而不是一个随手勾的开关。跳过时输出 `::warning` 注释,让后果在 run 页可见。

## 未决(留给用户)

`release/v1.3.x` 的 deepsearch 测试失败属该线既存状态(main 已修)。是否把它
cherry-pick 回 1.3.x 属产品决策(1.3.x 是历史维护线,main 已是 1.4.0),
故本次不擅自拉回绿,只提供显式跳过通道。

actionlint 全绿。
2026-09-29 13:40:25 +08:00
8cdcbf70fb ci: 发布流水线 —— release/** 推送即发版(tag/打包/发布/镜像全自动化)
## 设计

版本号唯一事实源是 internal/meta/meta.go 的 Version(仓库纪律),
所以发版动作 = 在 release/vX.Y.x 上把 meta.Version 改成目标版本后推送:

  prepare      读版本号;tag 已存在则整轮跳过(幂等闸门,改文档不会重发)
  build-linux  go build + go test 过门 → 下载资产 → 打包 3 deb + 1 tar.gz
               → 平铺 → 验证(deb 元数据/模型在位/校验和自验)→ artifact
  publish      打 tag → gh release create 传附件 → 回读下载验证校验和
  sync-gitcode 有 GITCODE_TOKEN 时同步 tag+附件到 gitcode(无则跳过不阻断)

## 关键事实(全部本地实测过才写进 workflow)

1. **编译不需要 ONNX Runtime**:onnxruntime_go 是 dlopen 方式(运行期才
   加载 .so),本地在清空 ORT 相关环境变量的条件下带 -tags=onnxruntime
   编译通过(83M)。CI 只需在**打包**时有 ORT(要打进 deb)。
2. **构建资产托管在 release ci-assets-v1**(已上传):
   chinese-clip-vit-b16-onnx.tar 719MB + onnxruntime-linux-amd64-1.28.0.tar
   24MB + SHA256SUMS。模型内容不随版本变 ⇒ 一次上传反复复用,CI 打包前
   下载并 sha256sum -c 校验。上传实测 3.2MB/s,构建期下载同源更快。
3. **打包链路在干净 worktree 全程实跑通过**(release/v1.3.x + VERSION=1.3.13):
   full 800M / server 726M / client 80M / tar.gz 841M,SHA256SUMS 平铺自验
   4/4 OK,full 包内确认含 TextEncoder/VisionEncoder.onnx 与 libonnxruntime.so。
4. **SHA256SUMS 的坑**:脚本把校验和写成平铺名(./xxx.deb),而产物在
   deb/ tar/ 子目录 ⇒ 直接 -c 会全 FAILED。workflow 里显式平铺后再验。
   (呼应 git-branching.md §七「校验和必须覆盖全部附件、只传一次」。)
5. ORT 资产补齐了缺失的 LICENSE + ThirdPartyNotices.txt(取自
   microsoft/onnxruntime v1.28.0 tag,与本地 .so 的内嵌版本号一致)——
   打包脚本的 stage_multimodal_assets 对这两文件非空校验,缺失即失败。
6. actionlint 全绿(修掉了 shellcheck SC2012:ls 改 stat 循环)。

## 已知边界

- arm64 发布产物暂缺(package-linux.sh 支持,但 CI 未配 QEMU 交叉;待需要时加 matrix)。
- Windows 安装器未纳入(需 electron-builder win 打包,单独验证后接入)。
- sync-gitcode 依赖 secret GITCODE_TOKEN(待用户配置;未配置时该 job 显式跳过)。
2026-09-29 13:07:36 +08:00
3cb4307677 fix: 修 CI 抓到的两类真实缺陷(.syso 破坏 arm64 + 测试硬编码 /etc)
第一次 CI 跑出 2 类失败,都是**本地以 root/amd64 跑永远看不见**的问题。
这正是建 CI 的价值:换一个环境就暴露了。

## 一、.syso 无条件被链进所有平台 → arm64 交叉编译必炸

CI 报:
  $WORK/b001/_pkg_.a(waiter.syso): 310766: unknown ARM64 relocation type 3
  (linux/arm64 与 darwin/arm64 两个 job 都红;amd64 两个都绿)

根因(已在本地用 Go 1.25.9 + arm64 精确复现):Go 会把**同目录的 *.syso
无条件链进任何 GOOS/GOARCH**,而这两个 .syso 是 Windows 资源对象
(x86-64 COFF,只含 .rsrc 图标段)。链进 arm64 目标即报「未知 ARM64 重定位」。

仓库其实**早就知道**这件事 —— deploy/packaging/build.sh:86-90 写着
「Go 会把同目录的 .syso 无条件链进任何目标」,并留了 hide_syso_for_target()
绕过,注释还点名「这正是 arm64 产物长期缺失的原因(曾被误判为缺 g++
交叉编译器)」。但那是打包脚本里的私有绕道:任何**直接 go build** 的路径
(包括 CI、包括本机原生 arm64 构建)都仍会撞上。修在源头而不是再加一层绕道。

修法分两种,因为两个文件的处境**完全不同**:

1. cmd/waiter/waiter_windows_amd64.syso(原 waiter.syso,git mv)
   waiter **仍支持 Windows**(package-windows.sh:68 明确构建 waiter.exe),
   所以不能删。按 Go 的文件名约定加 _windows_amd64 后缀 ⇒ 只在
   windows/amd64 被链入。实测:linux/amd64、linux/arm64、darwin/arm64、
   windows/amd64 四平台全部通过,且 Windows 产物的 .rsrc 段大小
   (00049eb8 字节)与改动前**逐字节一致** —— 图标没丢。

2. cmd/homed/{homed.syso,homed.rc} 删除
   homed 的 Windows 原生支持**已放弃**,五处独立来源一致:
     - README.md:251「homed 放弃 Windows 原生支持改走 WSL2」
     - cmd/homed/platform_windows.go 的 requireSupportedPlatform 直接拒绝启动
       (理由是设计性的:fd 继承 + 同段内偏移解引用,Windows 句柄模型无法表达)
     - package-windows.sh:4「❗安装器不往 Windows 装 homed」
     - build.sh:35「Windows 不再安装 homed.exe」
     - installer.nsi:230「homed 不再装到 Windows」
   即 homed.exe 即便构建出来也拒绝运行 ⇒ 图标资源毫无意义,却是 arm64
   构建失败的来源之一。顺带查明:homed.syso 与 waiter.syso 本是**同一个
   blob**(两个 .rc 指向同一 icon),属纯重复。

★ 由此留下一处**未修的残留**(已确认,不在本次范围):installer.nsi:297,313
  仍在创建指向 homed.exe 的快捷方式与 Run 注册项,而同文件 230 行已声明
  homed 不装 Windows。那是 Windows 安装器的独立缺陷,需单独处理。

## 二、internal/system 测试硬编码 /etc → 非 root 必失败

CI 报:
  system_test.go:51: expected archive to happen
  system_test.go:97: expected restore to happen

测试写死 target := "/etc/xxx.test.tmp" 并**忽略了 os.WriteFile 的错误**。
GitHub Actions runner 以非 root 运行 ⇒ 写 /etc permission denied ⇒ 文件
不存在 ⇒ ArchiveBeforeWrite 按「新建文件无需留档」返回 false ⇒ 断言失败。
本地以 root 跑则一路通过 —— 缺陷因此长期不可见。

修法:用仓库**已有**的 SetProtectedPaths([]string{临时目录}) 显式声明受保护
前缀(不再碰真实 /etc),defer SetProtectedPaths(nil) 复原默认。既去掉了对
root 的隐式依赖,也没有削弱被测语义(保护的仍是「受保护前缀下的文件」)。

## 验证

- go test ./... -count=1        全绿
- go build ./...                通过
- waiter 四平台交叉编译          全通过(含此前必红的 arm64)
- homed linux/amd64 原生构建     通过(确认删除 .syso 无害)
- Windows 产物 .rsrc 段          改动前后一致(00049eb8 字节)
ci-assets-v1
2026-09-29 11:11:34 +08:00
6d7de92bbd ci: 建立 GitHub Actions 流水线(六个 job,全部命令已本地实测)
## 为什么现在做

这次排查「QQ 收不到回复」花了大半程才定位到根因,途中我犯了两类错:
先断言「提示词没写 output_send 规则」(实际 buildSystemPrompt:48-52 写了),
又断言「适配器丢了内容」(实际两版等价、直连上游正常)。
两次都是**在无自动化判据的情况下凭局部证据外推**。

仓库已有 60 个 Go 包、`go build ./...` 仅 2.2 秒,成本极低却无人强制跑。
AtomGit 停用流水线后更无兜底,故迁到 GitHub 补齐。

## 设计原则:CI 里每条命令都是本地已实测通过的

不写「可能有用先试试」的步骤 —— 未验证的 CI 步骤会把假红灯变成常态,
最后所有人都学会忽略它。本文六个 job 的每条命令都本地跑过:

  go build ./...                      ✓
  go vet ./...                        ✓
  go test ./... -count=1              ✓(干净克隆亦通过)
  make check-client-versions          ✓
  go test -race core + waiter         ✓
  waiter/initconfig/mock-server 交叉  ✓(5 平台)
  npm test(cmd/gui)                 ✓
  make check-csrc                     ✓(告警/ABI/ASan+UBSan/跨架构)

## 六个 job

| job   | 覆盖                                                     |
|-------|----------------------------------------------------------|
| go    | build + vet + test + **跨平台客户端版本一致性**           |
| race  | 并发核心的竞态检测                                        |
| cross | linux/darwin/windows × amd64/arm64(仅可纯交叉的 3 个 cmd)|
| gui   | Electron 仓的 Node 测试                                   |
| csrc  | C 基础设施门禁                                            |
| docs  | 站点配置可解析                                            |

## 关键事实(都由实测确立,不是推断)

1. **只有 waiter/initconfig/mock-server 能纯交叉编译**。homed、memgc、
   homed-kb-migrate 依赖 cgo(gojieba / onnx),必须原生构建 ⇒ 不进 cross matrix。
2. **CGO 必须为 1**:gojieba 需要 cgo,`CGO_ENABLED=0` 下 internal/memory
   直接编译失败(实测)。
3. **`go test ./...` 不会碰到 cmd/gui**。该目录是纯 Electron(0 个 .go、
   无 go.mod),`./...` 只匹配含 Go 文件的包;只有显式 `go test ./cmd/gui`
   才报 "no Go files"。这不是缺陷,是 Go 的包匹配语义 —— 之前把它当
   [setup failed] 是误读。
4. **cmd/gui 的 npm test 零依赖**:三个 .mjs 只 import node: 内置模块
   (fs/url/path/vm)⇒ 不需要 npm ci、不需要 electron,秒级完成。
5. **测试自足,CI 上不会因缺本地服务而红**:webui 测试用 httptest 与
   `127.0.0.1:0`,真实 LLM 测试带 t.Skip 守卫。
6. **action 版本已核实存在**:checkout/setup-go/setup-node/setup-python 均用
   v7(经 GitHub API 逐个确认 tag 存在,避免「版本不存在 ⇒ 立刻红」)。

## 明确不进 CI(依赖真机/密钥/内网,否则只会变 flaky 噪音)

deploy-*.sh、waiter 真机(192.168.2.x)、`npm run test-live`(需真 Electron
+ Xvfb + 真后端)、scripts/kernel-stress/*、需 DEEPSEEK_API_KEY 的真实 LLM 测试。
2026-09-29 10:23:21 +08:00
d676adbd0e chore: 排除 SDK 仓的 skills/;清掉误建的 --help/ 目录
## skills/ 不该进本仓

SDK 仓新增了 `skills/`(hmapdev skill install 的源),本仓的
`.gitignore` 已排除 `tools/`、`docs/`、`example/`、`package/`、`scripts/`
等 SDK 仓自治范围,唯独漏了新加的 `skills/`。

`skills/` 是**文档**(skill 说明),归 SDK 仓管 —— 本仓经 go.mod 的
`replace` 只引用它的**编译必需文件**(sdk/*.go、go.mod、meta/meta.go),
文档不在其中。

⇒ 这不是新规则,是把已有规则的适用范围补齐。

## 误建的 --help/ 目录

我在验证 skill 时跑了 `hmapdev init demo`,但那次实际执行的是
`hmapdev init --help` 之类 —— `--help` 被当作**目录名**,在仓库根生成了
一个含 `go.mod`/`plg.json`/`plugin.go` 的 hmapdev 脚手架。

`plg.json` 里 `"name": "--help"` 坐实了这点。已删除。

★ 顺带记一条本机特性:`/tmp` 是 **tmpfs(内存盘) 只有 653M 可用**,
而 SDK store 有 4 个版本、备份要几 G ⇒ 大文件备份必须放 `/var/tmp`。
(这轮第一次备份就撞了 `No space left on device`。)
2026-09-28 11:30:55 +08:00
a417b5f927 feat(gui): 协议对齐 —— 补齐 6 个只读诊断端点 + 人设/反代面板
GUI 原来只用 22 个端点,服务端有 47 个。补齐**只读诊断类**:
agents / network / tracker / config / persona / proxy(+services)。

## 刻意不接 /login 与 /logout

那是 cookie 会话认证流程,而 GUI 走 `X-API-Key` 头(见 `api()`)。
接了不是"对齐",是接错。

## 三处新增

**总览诊断卡片**(renderDiagPanel):Agent 健康、LLM 可达、网络端点数、
文件变更集、数据目录、心跳间隔。全部走 `(x && x.y)` 安全取值 ——
任一端点没取到(老内核无该路由、连接断开)只显示 "-",不抛错。

**人设面板**(renderPersona):实测结构是
`{current_prompt, file_override, initialized}`。我第一版按 map 遍历,
结果只会显示三个字段名 —— 真机验证时才发现。改成展示提示词全文 +
两个状态卡。**只读**,编辑涉及保存/回滚/并发覆盖,与"协议对齐"是两件事。

**反代面板**(renderProxy):base_domain / mode / total / manual +
服务列表(`✓ gateway → 127.0.0.1:9890`)。实测字段是
`{name, host, path, url, target, ok, auth, websocket}`,不是我第一版假设的
`subdomain`。

## 加载策略

只加进 `refreshAll`,**不加** `refreshDataOnly`(后者每 15 秒一轮,
诊断数据不必高频轮询)。7 个新端点实测只 +3ms。

## 判据 protocol-align.test.mjs(10 条,真 Electron)

- 7 个 state 槽都取到真实数据
- 总览出现诊断卡片
- 人设面板**真的显示提示词内容**(不只查元素存在)
- 反代面板**真的列出服务**(查 `→` 出现)

### 判据踩的三个坑

1. **默认假 key 导致 9 项全红** —— webui 对错误凭据返回 200 + 登录页 HTML
   (`looks_like_login_page` 能识别),于是所有取数失败。看起来像
   「代码坏了」,实际只是认证缺失。改为默认从 `config.db` 读真 key。
2. **判据绕过了应用路径** —— 表达式里直接调 `refreshDiagData()`,
   于是把应用里的 `await refreshDiagData()` 注释掉,判据**仍全绿**。
   改为走应用自己的 `refreshAll()`。
3. **变异后 GUI 仍加载旧代码** —— `ensure()` 看到端口有页面就复用,
   注入变异后没重启 GUI ⇒ 又一次假绿。**变异测试必须先杀掉 GUI 进程。**

变异测试(注释掉调用点)⇒ 9 项变红,坐实判据验的是真路径。

## 门禁

- `npm run test-live`:22/22 通过(12 性能+滚动 + 10 协议对齐)
- `npm test`、`make test-gui`:全通过
- `go test ./...`:43 包 ok、0 FAIL
2026-09-28 10:45:54 +08:00
fba7dba373 perf(gui): 聊天页增量渲染 + 修「打开不在最新消息」(10× 提速)
用户报「聊天页面卡得让人没有用的欲望」+「打开 app 和 webui,没有停在
最新消息处,还要反复滑动」。真机实测(Xvfb + Electron + CDP)定位到两个
根因,都修了。

## 根因 1:renderChat 每次全量重建 innerHTML

200 条消息 = 3500 个 DOM 节点全部销毁重建。拆分测量:

    200 条:整体 234.6ms,其中 renderMd 25.4ms(**11%**)

⇒ markdown 渲染只占 11%,**89% 在 DOM 写入与布局**。
流式追加时每个放行的 chunk 都走这条路(200 条时每 chunk 6.6ms),
聊到几百条就是 0.5 秒/次 —— 这就是体感。

**改法**:按 `data-msgkey` 复用节点,四种策略按代价从低到高:
尾部追加(最常见)→ 头部前插(loadOlderChat)→ 局部替换 → 兜底整棵重建。

`data-msgkey` = role + 序号 + 内容长度 + 首尾片段。
★ **不能靠下标定位**:`loadOlderChat` 会 `unshift` 前插消息,下标整体位移。

实测:

| 消息数 | 改前 | 改后 | 改善 |
| --- | --- | --- | --- |
| 50 | 59ms | **8.2ms** | 7.2× |
| 200 | 235ms | **23.5ms** | 10× |
| 400 | 474ms | **38.2ms** | 12× |

关键是**次线性**了:400 条只比 200 条多 15ms(改前多 240ms)。

## 根因 2:滚动没落地

    scrollTo 被调: 1, 参数: {top: 23446, behavior: "smooth"}
    scrollTop: 0            ← 调了,但没生效
    可滚动上限: 22838

smooth 立即值 0、300ms 后只到 6894(上限 22838)⇒ **既慢又没到位**;
手动 `scrollTop = scrollHeight` **立即 22838 一次到位**。

原因:紧邻的 DOM 全量变更让 smooth 动画的起点算在**旧**布局上。
重建后本就不该有动画 —— 用户要的是「立刻看到最新」。

**改法**:`msgsEl.scrollTop = msgsEl.scrollHeight`。

## 判据 chat-perf.test.mjs(10 条,真 Electron 跑)

新增 `npm run test-live`(需 Xvfb + electron,**不进 make test** ——
它要起真浏览器、30 秒启动,不适合当门禁)。

- 性能:200 条 < 100ms(**产品体感阈值**,不是 benchmark 数字)
- 次线性:单条成本不随规模上升
- 滚动:打开即在底部、300ms 后不被带偏
- **正确性:增量不丢消息**(5 种增删改路径)—— 比性能更重要

### 写判据时踩的坑(都记在文件里)

1. **第一版测出「0ms / 0 DOM 节点」** —— `buildChatLayout()` 在**无后端连接**时
   走「请先添加连接」分支、聊天区压根没建 ⇒ **测不到**而非「不卡」。
2. **`ensureGui` 定义了但从未被调用** —— 重写文件时把调用丢了,
   而定义还在,看起来一切正常。
3. **`detached: true` 只脱离进程组、不脱离会话** —— 脚本结尾 `process.exit()`
   把刚起来的 GUI 带走(症状:`[tray] READY` 打了,判据却报「找不到页面」)。
   改用 `setsid`。
4. **`JSON.parse(e.data)` 裸调** —— CDP 的 onmessage 也会收到非 JSON 帧,
   抛在回调里既冒泡不到 await 也等不到 resolve ⇒ 整个判据挂死。
5. 我自己的滚动探针 `el.innerHTML=''` 让 `scrollHeight` 变 0,
   「到位」判定是假象。改用「保留内容、只改滚动方式」重测。

## 变异测试

把 `applyIncrementalChatRender(msgsEl, html)` 改回 `msgsEl.innerHTML = html`
⇒ 判据立刻红(实测 268ms + 严格线性)。

## 门禁

- `npm run test-live`:10/10 通过
- `npm test`、`make test-gui`:全通过
- `go test ./...`:43 包 ok、0 FAIL
2026-09-28 10:26:58 +08:00
9020590e13 test(proc): 修 grandchild 测试的三个设计缺陷(不是生产代码问题)
## 定位结论

`TestKillReturnsEvenWhenGrandchildSurvives` 曾在 `go test ./...`(600s 超时)
与 `make test`(20.4s FAIL)里失败,但**单跑 0.24s 通过**、连跑 3 次全绿
⇒ 「单跑绿、合跑红」。查下来是**三个测试设计缺陷**,
生产代码(`process.go`)没问题。

### 缺陷 1:名字说 Survives,实际测的是「被杀」

| 测试 | 源 | 孙进程 | kill(-pgid) 能杀吗 |
| --- | --- | --- | --- |
| …EvenWhenGrandchildSurvives | grandchildPluginSource | sleep 400,**不**设 Setpgid | **能** |
| …WhenGrandchildEscapesProcessGroup | escapingGrandchildSource | sleep 401 + Setsid | **不能** |

`grandchildPluginSource` 自己的注释写着「孙进程**不**设 Setpgid:它要留在
插件的进程组里」⇒ 第一个测试里孙进程不会 Survive。容易让人误以为
「脱组场景已被覆盖」,而它覆盖的是另一个场景。

**已改名** `…WhenGrandchildDiesWithProcessGroup`。

### 缺陷 2:判据数的是全系统进程

两个计数器扫 `/proc` 找 `"sleep 400"` / `"sleep 401"` 字符串,
**不区分父子关系** ⇒ 同机任何命中同样 cmdline 的进程/容器都串味。

原注释记过一次前车之鉴(「我第一版就踩了:明明单跑通过,合跑却红」),
但当时只加了 base 快照,**没解决全局匹配这个根因** —— base 救不了
「别的测试中途拉起 sleep 400」。

**已修**:新增 `procPPid()`,两个计数器都限定 PPid 属于本测试的插件。
顺带补 `e.Name()` 的 `Atoi` 校验(原来会把 /proc/self、/proc/net 也读一遍)。

### 缺陷 3:defer 清理「拿不到 pid 就整个跳过」

`if pid := pluginPid(p); pid > 0 { Kill }` 在 pid 取不到时静默跳过
⇒ 残留 sleep 400 污染后续测试 ⇒ 变成下一个测试的假失败。

**已修**:新增 `cleanupSleepMarkers(marker)`,按唯一 cmdline 标记兜底清理。

## ★ 我被推翻的一个假设

我一度认定根因是 `waitLoop` 里 `p.cmd.Wait()` **先阻塞**、拆管道在**之后**
(`process.go:359-367`)—— Go 的 `exec` 里 `Wait()` 会等 copy goroutine,
而那些要等所有管道写端关闭,孙进程持有着 ⇒ 死锁。

**实测推翻了它**:把拆管道提到 `Wait` 之前,那个测试 **5 次全 FAIL**
(改前只是偶发)。真正的根因是上面三个测试设计问题;「Wait 阻塞」只是
**被孙进程持管道放大**的效应。

⇒ 改生产代码不但没修好,还把偶发变成必现。**先证明因果再动手。**

## 判据:grandchild_design_test.go(4 条)

★ 它是**查源码文本**的,我一改源文件(改名/加 ppid 限定/加兜底清理),
锚点就全过期 ⇒ 三条判据一起红。

⇒ 判据自己被重构打断时,要改的是**判据的锚点**(认新旧两种形态),
不是回退修复。最后把判据①从「解函数体比对 spawn 参数」简化为
「只问名字是否还说 Survives」—— 少耦合一层,少失效一处。

变异测试三个都抓到:改名回 Survives / 抽掉 ppid 限定 / 去掉兜底清理。

## 门禁

- 全量 `go test ./...`:**43 包 ok、0 FAIL**
- `internal/plugin/proc` 连跑 **5 次全绿**(原来会红的地方)
- `go test -race ./internal/plugin/proc/`:ok
- 无 sleep 400/401 残留

## 顺带

`67def30` 之后 README 的 biome 格式化不再 churn:仓库**没有** biome 配置,
格式来自流水线默认 ⇒ 每次提交后它都会把工作区改脏。我这次会话里反复
`git checkout --` 把它丢掉,那是和流水线对抗。**提交后 biome 再跑就是
no-op**,问题根除。
2026-09-28 10:00:10 +08:00
67def306e5 style(readme): 接受流水线的 markdown 格式化
三处纯格式,**无语义变化**:
- 嵌套列表 `+ ` → `- `(渲染效果相同)
- 表格分隔符 `|---|---|---|` → `| --- | --- | --- |`

## 为什么要提交而不是继续丢弃

仓库**没有 biome 配置**(无 biome.json),格式来自流水线默认。
⇒ 它每次跑都会把 README 与 cmd/gui/renderer/app.js 改成 dirty 工作区。

我这次会话里反复 `git checkout -- README.md cmd/gui/renderer/app.js`
把它丢掉,**那是在和流水线对抗,纯属白费力气**:每次提交后它又变 dirty。

提交后 biome 再跑就是 no-op ⇒ 永久不再 churn。

★ 教训:可自动复现的格式化,正确做法是**接受并单独提交**,
而不是每次手动回退。回退只是把冲突推迟到下一次提交。
2026-09-28 09:40:37 +08:00
bf2d6867e7 test(webui): 压测补齐另外 3 个插件的路由 —— 我第一版漏了两个
第一版只压了 webui 的 `/api/v1/*`。核实后发现 **8080 上注册 HTTP 路由的
插件有 4 个,监听端口还不止 8080**(`ss -ltnp` 实测):

| 端口 | 插件 | 路由 | 认证 |
| --- | --- | --- | --- |
| 127.0.0.1:8080 | webui | /api/v1/* | config_webui.api_key |
| | remotedevice | /api/v1/device/* | config_remotedevice.ws_token |
| | kbtree 子路径 | /api/v1/knowledge/tree/{categories,counts} | config_webui.api_key |
| 127.0.0.1:9876 | **pluginmgr** | /plugins(**无** /api/v1) | **无需认证** |
| 127.0.0.1:9892 | **kbtree** | /categories /counts(**无**前缀) | config_kbtree.token |
| 127.0.0.1:9890 | remotedevice | 设备 WS 网关(非 REST) | 不压 |

⇒ 「打 /api/v1/*」这个假设只对 8080 上的 webui 成立:`:8080/plugins`
实测 **404**。三套认证各不相同,脚本现在按分组取对应 token。

## 端点 18 → 24

新增:`/api/v1/device/online`(remotedevice)、`/plugins`(pluginmgr)、
`/categories` `/counts`(kbtree 根路由)、以及 webui 前缀内的
`/api/v1/knowledge/tree/{categories,counts}`。

收 `/api/v1/device/online` 之前逐行读过实现:仅 `GET` +
`registry.OnlineList()`,纯读。**不收** `/device/push`(向真实设备下发)、
`/device/ws`(长连接)、`/device/{id}`(语义未核实)。

## 实测(24 端点 × 3 档,2880 请求零失败)

| scale | 请求 | 吞吐 | p50 | p95 | p99 | max | 成功 |
| --- | --- | --- | --- | --- | --- | --- | --- |
| 1 | 480 | 953 req/s | 4.0ms | 23.0ms | 34.7ms | 40.6ms | 480/480 |
| 2 | 960 | 1310 req/s | 7.9ms | 32.4ms | 48.4ms | 72.3ms | 960/960 |
| 3 | 1440 | 1422 req/s | 12.0ms | 42.7ms | 60.5ms | 88.7ms | 1440/1440 |

压测期间服务端 `active`、0 个 5xx。

**p99 随并发单调上升**(34.7 → 48.4 → 60.5ms),符合排队预期。
上一轮曾出现 scale=2 的 p99 反常地高于 scale=3,当时机器上另一个 agent 的
chromium 占 766% CPU ⇒ 环境噪声,已明确不作为性能特征。

## 又踩了一次前缀的坑(两种方向都踩了)

1. 「表里存路径后半段 + 代码按 group 补前缀」⇒ remotedevice 拼成
   `/api/v1/api/v1/device/online` ⇒ 落到 webui 兜底路由、
   返回 **200 + 登录页 HTML**(靠 `looks_like_login_page()` 抓到)。
2. 反过来「表里已含前缀 + 代码仍补」⇒ webui 组全 404。

⇒ 最终统一成**表里写完整路径、代码不补**。两次都是靠 `--probe` 抓到的,
这正是它存在的理由。

## 顺带修掉我写错的一处

`contextlib.suppress(sqlite3.connect)` —— `connect` 是函数不是异常类,
`suppress` 会抛 `TypeError`。改回显式 `try/except sqlite3.Error`,
并把 `config.db` 的打开方式保持 `mode=ro`(压测不碰生产库写路径)。

## 文档里我自己写错又改正的一处

§7 表格里 scale=1 一行先写成 1680/2013/5.4ms,核对 JSON 后实为
**480/953/4.0ms**(24 端点 × 20 轮)。已订正,并加了提交前的
JSON 逐项比对,三档现已全部一致。
2026-09-28 09:33:22 +08:00
034945890c test(webui): WebAPI 只读端点压测 + 实测 2160 请求零失败
打的是**生产实例** 127.0.0.1:8080。

## 为什么只压只读端点

压测绝不能改状态。18 个端点**逐个探测确认**是 GET + 只读:
小(/status…/proxy/services,104B–1.8KB)、中(/plugins…/knowledge,
4.4KB–53KB)、大(/kernel 157KB、/memory/graph 338KB、/knowledge/tree 425KB)。

**排除**:/chat(真调 LLM)、/chat/interrupt(中断在跑的任务)、
/settings/* 与 /plugins/*(改配置)、/login /logout(改会话)、
knowledge/memory 写接口、/device/*(控制真实设备)。

## ★ 两条判据(都不是"看有没有报错")

### 1. 必须先 `--probe`:webui 无认证时返回 200 + 登录页 HTML

含 `THEME_PLACEHOLDER`,**状态码是 200**。只看 `http_code` 会把登录页
当成健康响应 ⇒「全部 200」是假的。脚本因此额外校验响应体。

### 2. 端点表漏了 `/api/v1` 前缀 ⇒ 18 个端点全 404

第一版把端点存成路径后半段(`"/status"`),拼出
`http://127.0.0.1:8080/status`,而真实路径是 `/api/v1/status` ⇒
**全部 404**,同一时刻 curl `/api/v1/status` 却是 200。

⇒ **压测脚本必须先 probe 再压。** 不 probe 的话那一跑的结论会是
「webui 全挂」,完全错误。这条已写进手册。

## 实测(2026-09-28)

| scale | 请求 | 吞吐 | p50 | p95 | p99 | max | 成功 |
| --- | --- | --- | --- | --- | --- | --- | --- |
| 1 | 360 | 779 req/s | 6.7ms | 26.8ms | 45.5ms | 51.6ms | 360/360 |
| 2 | 720 | 788 req/s | 12.2ms | 52.6ms | 163.0ms | 213.5ms | 720/720 |
| 3 | 1080 | 1038 req/s | 16.4ms | 60.4ms | 79.7ms | 123.0ms | 1080/1080 |

**2160 请求零失败**;压测期间服务端 `active`、0 个 5xx、0 个 webui 错误,
QQ/agent 链路未受影响,homed CPU 仅 1.2%。

### 重响应不是瓶颈(并发 1 → 16)

| 端点 | 大小 | 并发 1 | 并发 16 | 吞吐 |
| --- | --- | --- | --- | --- |
| /status | 0.2KB | 104 req/s | **1781 req/s** | 320KB/s |
| /kernel | 153KB | 126 req/s | 375 req/s | 19 → **57 MB/s** |
| /memory/graph | 330KB | 70 req/s | 410 req/s | 23 → **135 MB/s** |
| /knowledge/tree | 415KB | 79 req/s | 717 req/s | 33 → **297 MB/s** |

大 JSON 端点的 p50 **不随并发上升**(/knowledge/tree 12.3ms → 8.9ms,
排队更充分、效率更高)⇒ 瓶颈不在 JSON 序列化。

## ⚠ 一处未下结论的观察

scale=2 的 p99(163ms)反而**高于** scale=3(79.7ms)。看着反常,但当时
机器上另一个 agent 的 chromium 占 **766% CPU**(另有 cjpm/cjc 在编译)
⇒ 是**环境噪声**。

**未在可比条件下重测就不下结论** —— 不拿这一组当性能特征。要判定需先
固定负载条件再跑。这条也写进手册。

## 顺带

ruff 报的三条 blocker 都修了:`pct()` 空样本不再抛错(调用方直接拿去做
f-string 格式化)、写 JSON 失败明确报错而非静默丢报告、错误体读取用
`contextlib.suppress` 免得二次异常盖掉真正的状态码。
2026-09-28 09:22:16 +08:00
3b08f04897 fix(gui): 401 重试无限递归 —— 我上一个优化放大的 bug
## 真机实测发现的

Xvfb + Electron + CDP 真跑,发现 `api()` 的 401 分支**无限自我递归**:

    第1次: lock=false → 重登 → lock=false → return api()   ← 递归
    第2次: lock=false → 重登 → lock=false → return api()   ← 又递归
    …

那把锁的语义本该是「已经重登过一次,别再登」,但它在递归**之前**就被
清掉了 ⇒ 每层递归看到的都是 `false`。真机实测 **fetch 被调 13 次、
重登 12 次**才被我的探针上限截断。

## 为什么这与我的上一个提交直接相关

`7ff0331` 把 401 分支的固定等待从 800ms 降到 30ms(修「认证过期时每个
请求白等 0.8s」)。但重试**没有次数上限** ⇒

- 改前:每 800ms 慢速空转
- 改后:每 30ms 快速烧 CPU + 反复打服务端

**我的优化把这个 bug 放大了。** 真机上探针调用 `api()` 直接挂死,
我起初还以为是我的测试写法问题。

## 修法

两处递归点(真 401 / 门户返回 200 但内容是登录页)都改成:

    try {
      return await api(p, o);
    } finally {
      window._haReloginLock = false;
    }

`finally` 保证递归抛错时也释放锁 —— 否则会把后续所有请求都锁死成直接 401。

## 判据 retry-guard.test.mjs

在 `node:vm` 沙箱里跑**真实抽出的 `api()`**,用恒回 401 的 `fetch` 驱动,
统计真实调用次数。

### 写这条判据时踩的四个坑(都记在文件里)

1. **先给了假绿灯**:把 401 分支当独立函数体执行,但那块以
   `return api(p,o)` 结尾、外面没有调用它的上下文 ⇒ 我从未真正进入那个
   `if` ⇒ `api 递归=0` ⇒ 什么都没测到。改成跑**真实 api()** 才对。
2. **递归时忘了保持 `r.status===401`**:真实场景是「重登后凭据仍是错的」。
   漏了它 ⇒ 递归那层不进 401 分支 ⇒ 又一次假绿灯。
3. **沙箱 `setTimeout` 只记录不执行**:`api()` 用它做超时控制
   (`setTimeout(() => ctl.abort(), to)`)⇒ AbortController 永不被 abort
   ⇒ 表现为「fetch 只调 1 次、8000ms 被当成 401 等待」。
4. **沙箱缺 `clearTimeout`** ⇒ 抛 `clearTimeout is not defined` ⇒
   整段在 fetch 之后就断了 ⇒ 永远走不到 401 分支。

★ 共同点:**沙箱不完整 ⇒ 静默地什么都没测 ⇒ 假绿灯**。
判据自己给假绿灯比没有判据更危险。

### 变异测试(精确锚点,验证判据真能抓)

把第一处 try/finally 退回「递归前清锁」⇒ 判据立刻红
(`fetch 被调 13 次`);恢复后全绿。

★ 第一次做这个变异时我误判「判据漏抓」—— 实际是我的变异脚本用了模糊
锚点、**压根没改到文件**(`grep` 显示 return await 从 2 变 1,但 `sed`
命中的是另一处)。两个信号矛盾时先坐实文件状态,别急着改判据。

## 顺带把 `make test` 的门禁修好(1f2b078 / 95bdd18 之外)

新判据已接入 `npm test`,实测 14 项通过、`make test-gui` 全绿。
2026-09-28 09:11:58 +08:00
db483c2c0c docs(runbook): 记「判断线上跑哪次构建」的正确判据(我今天差点白部署)
## 线上其实已经是修好的版本

去部署 `1b95d0e`(webui 总览 5 个 KPI 空)前核实,发现:

    /api/v1/status → {"commit":"1b95d0e", "startedAt":"2026-09-27T23:23:39"}

23:23 那次部署**不是我做的**(我只在 21:50 部署过),已经包含
`1b95d0e`。`/api/v1/kernel` 里 5 个 KPI 依赖的字段也全部有值
(`plugins` 39 个、`llm`/`memory`/`documents`/`runtime`/`onnx` 非空)。

⇒ **不需要部署。**

## ★ 为什么 `strings` 不能用来判断

webui 等插件的静态资源是 `//go:embed` **编译进二进制**的
(`internal/plugins/webui/handler.go:27`),内容取决于**构建时**磁盘上的
文件。⇒ 已提交但未部署的改动,线上二进制的 `strings` 里**也可能**
出现新代码片段。

我据此差点白重启一次生产。同一天还撞上**字节数完全相同**的巧合
(86811464),更掩盖了这点 —— 大小相同更让人以为"没变化,不用管"。

## 正确的判据顺序

1. `/api/v1/status` 的 `commit` —— 线上在跑什么
2. `git log <commit>..HEAD` —— 差哪些提交
3. 那些提交里**有无运行时改动**(`internal/`、`cmd/`)—— 只有它需要部署
4. 文档 / 判据 / 部署脚本类提交**不需要**部署

## ⚠ 那条命令必须带认证头

无认证时 `/api/v1/status` 返回**登录页 HTML**(200 + `THEME_PLACEHOLDER`),
`grep '"commit"'` 匹配不到 —— 看起来像"命令没输出",实际是认证缺失。
与 GUI 客户端 `api()` 专门检测 `THEME_PLACEHOLDER` 是同一件事。

## 顺带:内置插件 vs 独立二进制

`internal/plugins/<name>/` 编译进 homed;`plugins/<name>/plugin.bin`
是独立插件,要单独构建部署。**目录存在不等于有独立二进制** ——
`plugins/webui/` 目录存在但 **0 个文件**,走内置。

实测(2026-09-28):`webui`/`cmd`/`seq` 内置,`qq` 独立。
改内置插件只需重编 homed。
2026-09-28 08:52:12 +08:00
f96f67707a feat(watch): 站点漂移巡检(只读,有差异才提醒)
`site-drift-watch.sh` 巡检 192.168.2.106 上的两个站,**只读不动**:
不构建、不上传、不碰线上任何文件。档位是「有差异就提醒」而不是
「自动推」—— 文档站发错了是公开可见的,宁可等人点一下。

判三类信号:
1. 线上漂移:.106 上的站 ≠ 本机产物/site 源(逐字节 md5 清单比对)
2. 源码漂移:git HEAD 比产物新 ⇒ 提交了但没重新构建部署
3. 探活失败:.106 或 NapCat 挂了(比文档漂移紧急)

不复用 `deploy-sdk-site.sh --check`:那脚本输出是给人看的彩色文本,
拿来当机器判断依据太脆(改个文案就失效)。md5 清单逻辑很短,
这里复刻一份并在注释里指向 deploy-sdk-site.sh 保持同步。

## `.drift-watch/` 加入 .gitignore

里面的 `state` 是运行时状态(含时间戳与指纹,每次跑都变),不该入库。

## 提交前核实(两处我的检查写错了,脚本本身没问题)

- 我查"凭据"命中 1 处 ⇒ 实为注释里的「烧 token」,指 LLM token 非密钥。
  真实密钥形态(`sk-*` / `password=`)**0 处**。
- 我查"部署调用"命中 6 处 ⇒ 全是**注释与提示文本**(提醒人去跑
  `deploy-sdk-site.sh`)。实际执行 `rsync`/`scp`/调用部署脚本**均 0 处**,
  `rm -rf` 0 处 —— 与它「只读」的声明一致。
- `set -uo pipefail` 少了 `-e`:**有意为之**。巡检要在某项检查失败时
  继续跑完其余项,`-e` 会中途打断。实测跑一次四项全绿、无差异。
2026-09-28 08:45:03 +08:00
95bdd18827 fix(make): test-gui 不再被前序失败短路(门禁曾形同虚设)
## 问题

`make test` 原本是 make 的**依赖链**:

    test:
    	$(GO) test ./...
    	@$(MAKE) test-gui
    	@$(MAKE) csrc-test
    	...

`go test ./...` 一旦 FAIL,make **立即中止** ⇒ 挂在它后面的目标一行都不跑。

实测坐实:`internal/plugin/proc` 偶发 FAIL 时,`make test` 的日志里
**找不到 test-gui 的任何输出** —— 刚接进去的 GUI 判据根本没被执行。
门禁挂上去等于没挂。

## 为什么不能简单用 `-@$(MAKE) test-gui`

`-` 前缀会**吞掉 GUI 判据自己的失败码** —— 判据红了 make 照样绿,
等于给假绿灯。那比短路更糟:它让人以为门禁在生效。

## 做法

全部跑完,最后统一判退出码:

    test:
    	@rc=0; $(GO) test ./... || rc=$$?; \
    	 $(MAKE) test-gui || rc=$$?; \
    	 $(MAKE) csrc-test || rc=$$?; \
    	 $(MAKE) check-csrc || rc=$$?; \
    	 $(MAKE) check-codec-cgo-only || rc=$$?; \
    	 exit $$rc

既保证每一步都跑,也保留每一步自己的失败。

## 验证(注入失败实测,不是推断)

往 `TestKillReturnsEvenWhenGrandchildSurvives` 里塞 `t.Fatal` 制造必然失败:

| 验证项 | 结果 |
| --- | --- |
| `make test` 退出码 | **2**(非 0,失败被上报) |
| proc 包 FAIL | 抓到 |
| **GUI 判据输出** | **仍执行** ← 这是要证明的那件事 |
| csrc-test | 也执行了 |
| 汇总 | ok=43 FAIL=1 |

随后已恢复该测试文件(`git checkout` + 确认 0 处残留 `t.Fatal`),
并复跑该包确认 ok 10.6s。

全绿路径也验过:`make test` 退出码 0、ok=44 FAIL=0、GUI 判据执行。
2026-09-28 08:43:04 +08:00
a96ba70db9 docs(make): 记下 proc grandchild 测试本身不稳定(现象,未下根因)
`make test` 的后置验证里发现 `internal/plugin/proc` 会 FAIL,实测形态:

- 单独跑**同一命令**:`ok 11.5s` / `FAIL` 交替出现(至少各一次)
- 全量并发跑:曾 600s 超时(`panic: test timed out after 10m0s`),
  也曾 90s 就 FAIL
- 失败测试固定是 `TestKillReturnsEvenWhenGrandchildSurvives`,
  伴随日志 `[proc] audit 退出: signal: killed`

与本次改动(7ff0331 SSE 退避、1f2b078 判据)无关联:改的是
`cmd/gui/renderer/app.js` 与判据脚本,没碰 `internal/plugin/proc`。

症状**像** fork/kill 的进程组语义在容器/并发下不稳(孙进程 setsid
脱组后杀不掉 ⇒ Wait 挂死),但**尚未定位到根因**,因此 Makefile 里
只记现象、不下结论。

★ 记这一条是因为我自己先踩了坑:一开始连跑 3 次全绿,我就准备写
「单跑稳定、仅并发偶发」;紧接着同一命令又 FAIL 了。**单跑通过不能
当结论** —— 这类 fork/kill 测试必须重试才能给出可信判断。
2026-09-28 08:30:44 +08:00
1f2b078322 test(gui): 行为判据 + 接进 make test(此前无人能跑)
## 为什么加行为判据

`sse-backoff.test.mjs` 检查源码**形状**(有没有清零、上限自不自洽)。
但形状对 ≠ 行为对:把清零写到 `reader` 取流**之后**,形状检查照样通过,
而实际仍在用旧计数重连。

新判据 `sse-backoff-behavior.test.mjs` 从**真实源码**抽出退避表达式并
在 `node:vm` 沙箱里求值,用假状态记录实际等待。实测量化:

    历史累计 8 次后建连成功再断流 → 等 1000ms(改前会是 32000ms)
    连续 6 次「建连成功→断流」  → 1000,1000,1000,1000,1000,1000ms

## 变异测试(都抓到)

| 变异 | 形状判据 | 行为判据 |
| --- | --- | --- |
| 清零挪进 setTimeout 内 | 通过 | **红** ← 只有行为抓得到 |
| 删掉「建连后」清零 | **红** | 通过 ← 暴露了行为判据的盲区 |
| pump 上限改回 60000 | **红** | — |
| 30ms 改回 800ms | **红** | — |

第二行促成了「行为 0」:建连后清零原本不在被验证的路径上
(抽取锚点只抓 pump 那一处),补了独立检查。

## ★ 判据本身踩的坑(都写进文件注释)

1. **别包假 setTimeout**:`exprSrc` 本身就是延迟数值
   (`setTimeout(fn, <延迟>)` 的第二个参数),包一层让结果恒为 null,
   三项全红。
2. **别用 `new Function`**:等价于 eval,是安全反模式。改用 `node:vm`
   的 `runInNewContext`(官方受限环境,拿不到宿主作用域,带 1000ms 超时)。
3. **一个正则兼容两种幂运算形态会取错捕获组**:`Math.pow(2,x)` 比 `2 ** x`
   多一层括号 ⇒ 组数差 1 ⇒ `r[length-1]` 取到 NaN。
   最终形态是**定位与取值分离**:正则只定位(不捕获数字),数字单独取。
4. **`String.raw` 拼接正则不可用**:`\\.` 保持字面双反斜杠(去找字面的
   "\."),且拼接后捕获组编号不可控。

## ★ 这些判据此前没有任何入口会跑

`cmd/gui` 是**纯 Electron 目录**(0 个 `.go`、无 `go.mod`),Go 通配会
跳过它 ⇒ 判据挂着也没人执行。现已接入:

- `cmd/gui/package.json` 加 `"test"`
- `Makefile` 加 `test-gui` 目标,并挂进 `test`
- 无 node 时显式 SKIP 而不是静默通过

```bash
make test-gui     # 或 cd cmd/gui && npm test
```

## 顺带说明

`go test ./cmd/gui` 报 `no Go files [setup failed]` **不是回归**:
该目录 0 个 `.go` 文件,只有**显式点名**才报。仓库门禁用的三种形态
(`go test ./...`、`go test ./cmd/...`、Makefile 的 `test`)全部通过。
2026-09-28 08:16:51 +08:00
7ff0331c50 fix(gui): SSE 退避计数从不重置 —— 消息流不稳的一个共因
## 现象

用户报 GUI「消息流不稳、动画不连贯、看着卡」,四类症状都有:滞后、
卡顿、闪断、资源高。定位到**同一个**共因,不是四个独立问题。

## 根因 1:退避计数只增不减

`state._sseRetryAttempts` 的自增只发生在 `connectFetchSSE` 的 catch 分支
(连接**建立**失败),而 `pump()` 中途断流后的重连**只读它算延迟,
从不清零**:

    Math.min(1000 * Math.pow(2, Math.min((state._sseRetryAttempts || 0), 5)), 60000)

后果:只要历史上累计过 5 次,**之后每次断连都固定等 32s** —— 哪怕这次刚
成功连上、说明服务端和网络都好好的。而"成功连上"恰恰是最该重置的信号。

修:建连成功后清零(reader 取流之前),断流重连前再清一次。

## 根因 2:外层 60000 上限是死代码

`2^5 = 32s < 60s` ⇒ `Math.min(..., 60000)` 永远达不到,**真实封顶是 32s**。
改为 32000,让声明值与实际一致(判据会验这一条)。

catch 分支那处保留 60000:它语义不同(连接根本没建起来,attempts 已 +1,
退避本就该更长),上限放宽无害 —— 判据按各自语义分别判定,不一刀切。

## 根因 3:401 重试固定空等 800ms

`api()` 里每个 401 都走 `syncConnAuth()` + 固定 `setTimeout(800)` 才重试。
认证过期时**每个**请求白等 0.8s,并发几个就叠加成明显的「卡」。
`syncConnAuth` 本身就是 await 的,返回即代表凭据就绪 ⇒ 降到 30ms
(留一点让 setAuth 的 cookie 落盘)。

真 401 与「门户返回 200 但内容是登录页」两个分支都有这处等待,两处都改。

## 判据:cmd/gui/sse-backoff.test.mjs

`cmd/gui` 无测试框架(package.json 只有 start/dev),app.js 是 203KB 单文件。
判据从**真实源码**提取退避表达式并求值,而不是抄一份逻辑重写 ——
抄写的那份会和真实代码漂移,而漂移本身就是这个判据要防的东西。

3 项 + 变异测试(删清零 / 改回 60000 / 改回 800ms,三次全部被抓到)。

### 判据本身踩的三个坑(都记在文件注释里)

1. **正则两种形态括号数不同**:`Math.pow(2, x)` 比 `2 ** x` 多一层括号。
   早先只按 pow 写,biome 规范化成 `**` 后**静默失配**。
2. **不能用 String.raw 拼接正则**:`\\.` 保持双反斜杠字面量(去找字面的
   "\."),且拼接后**捕获组编号不可控** —— 实测 cap 解出 NaN。
3. **一个正则兼容两种形态会取错捕获组**(组数差 1)⇒ 改为
   **定位与取值分离**:正则只负责定位(不捕获数字),数字单独取。

### 只认 pump 那处上限自洽

catch 那处 `60000` 不可达但无害(语义是"最多等一分钟")。
判据对两处**分别**判:pump 要自洽,catch 只要有有限上限。

## 排查时坐实的两件事

- **`go test ./cmd/gui` 报 `[setup failed]` 不是仓库问题**:`cmd/gui` 有
  **0 个 `.go` 文件**(纯 Electron),Go 通配会跳过它,只有显式点名才报。
  `go test ./...` 实测退出码 0、43 包 ok、0 处提及 cmd/gui。
- **biome 会顺手把 `function () {}` 改成箭头函数**(本次混入 12 行)。
  与本次修复无关,已从 HEAD 干净重放,最终 diff 只有 3 处实质改动
  (24 增 3 删,零无关格式化)。
2026-09-28 07:59:15 +08:00
1b95d0ef2d fix(webui): 修总览 KPI 长期空值(我上轮懒加载改出来的)+星图改银色
### ★ 严重问题:总览页 8 个 KPI 里有 5 个是空的
线上实测(https://homeagent.jianfgit.xyz):
  ["运行中状态","1h 22m 18s运行","0插件","v1.4.0…版本","—LLM",
   "—记忆","—文档","—运行时"]
对照 state:{"status":true,"kernel":false,"settings":0,"runtime":true}

根因是**我上一个提交(8a36be0 按页签懒加载)引入的**:
总览的插件数/版本/LLM/记忆/文档/运行时全部读 `state.kernel`
(`updateOverview` 里写作 `(k && k.plugins)` 这类安全取值),
而我把 kernel 从 overview 的数据块里删掉了。
⇒ 缺数据时不报错、**只显示「插件 0、记忆 —、文档 —」**,
看起来像「服务坏了」而不是像 bug —— 这类静默降级最难自查。

★ 教训(已写进代码注释):依赖分析必须覆盖**整个调用链**。
我当初只 grep 了 `renderOverview` **直接**读的 state.*,漏了它间接
调用的 `updateOverview`。逐页重核后确认只有 overview 漏配,其余页签
(plugins/settings/kernel 各自要的)本来就是对的。

修复:overview 的 fetch 补回 kernel。kernel 拉过一次后不再重拉
(starmapFetchBlock 的节流),152KB 只在首屏付一次。
实测:["运行中状态","1m 15s运行","17插件","v1.4.0HomeAgent版本",
      "deepseekLLM","1200/893记忆","0文档","39 · 11M运行时"]

### 星图配色改银色(用户裁定)
SM_COLOR_DIM: 0x7d8a9e(灰蓝)→ 0xc8ced8(银色)。
实测 colors: ["7d8a9e"] → ["c8ced8"]

### 关于「页面还是绿色」这个现象
线上内联的颜色实测已是 0x7d8a9e,缓存头也正确
(cache-control: no-cache, no-store, must-revalidate),
浏览器复现同样是灰蓝色 ⇒ 那是**已打开标签页里的旧 JS**:
页面 HTML 变了,但没重新加载的标签页不会自己换。
本提交部署后需**刷新页面**(Ctrl+Shift+R 强刷)才会看到新配色。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-09-27 23:20:01 +08:00
81ac13f266 docs(runbook): 补 §6 部署单元清单,并纠正一次误判的记录
## 补齐漏掉的单元

`cmd/` 下共 7 个可构建入口,服务端只部署 3 个:

| 单元 | 入口 | 服务端部署 |
| --- | --- | --- |
| homed(内核) | `cmd/homed` | ✅ 本机 `/usr/local/bin/homed` |
| waiter / waitercli | `cmd/waiter` | ✅ 106、30 的 `/opt/waiter/waiter` |
| 站点 | `site/`、`site_build/` | ✅ 106 的 `sites/` |
| **GUI** | `cmd/gui` | ❌ **Electron 桌面应用,随客户端分发** |

**GUI 不在服务端部署**(已确认生产无 `*.service`、无进程)。它与 waiter
是两端:GUI 用 `devicebridge_dll.js` 走设备桥协议连服务端 waiter,
`17b010d` 修的正是 GUI 侧 bind 判 ok 与登记状态暴露。
⇒ 排查 GUI 问题要看**用户机器上的客户端**,不是 `homeagent.service` 日志。

另记:核查"有没有进程"时 `ps -ef | grep -c "[e]lectron"` 会把**自己的
grep 命令行**算进去返回非 0(实测返回 2,实际 0)—— 要看列出的内容,别只看计数。

## 本机 waiter 与 106/30 不同步

本机也留了一份 `/usr/local/bin/waiter`,`v1.3.2-153-gff69127`(2026-09-25
构建),**早于** main 在 09-26 合入的那批修复(设备反复掉线、bind 判 ok、
服务端发现自动链接),因而缺它们。106/30 已是 `1.4.0`。
本机这份无进程无服务,不影响生产设备桥;更新方式已记入文档。

## 记一次误判:判定"是否已合入"要按内容查

我曾用 `git merge-base --is-ancestor ff69127 origin/main` 判定"这批提交
没进 main",并推断"存在一条未合入、只靠 reflog 撑着的 5 提交线"。**该结论是错的。**

真实情况:这批改动在 2026-09-26 以**新 hash** 重做并进入 main,五对逐字节一致
(diff 均为 0 行):`1acbd39`→`5467a9f`、`3d30482`→`3a860b9`、
`078517e`→`4393872`、`aaafaac`→`a15d8d0`、`ff69127`→`17b010d`。

⇒ 只查**旧 hash 的祖先关系**会误判;同一改动被重做为新 hash 时
   `merge-base` 必然说不包含,而 `git merge-tree` 报的 13 个"冲突"
   正是同源改动做两遍的必然结果,**不能当冲突去解**。

顺带修正一处归因:106 此前长期无 `online` 日志的成因是
`a15d8d0`(设备反复掉线/静默失联:ping 路径断连 + bind 结果无人处理),
2026-09-26 已进 main,今天部署的 `1.4.0` 包含它。
2026-09-27 23:10:49 +08:00
512effa1ad feat(deploy): 站点部署流水线(SDK 文档站 + introduce)
`deploy-sdk-site.sh` 覆盖两个静态站,与 homed / waiter 是独立部署单元:

- SDK 文档站:本地 `third_party/homeagent-sdk/site_build/` → 106 的
  `/vol1/docker/navi-data/sites/sdk`
- introduce:本地 `site/`(零构建,源即产物)→ `sites/introduce`

用法:`--check`(只核对差异)/ 默认(构建+部署+验证)/ `--rollback <备份名>`。
`--check` 逐字节比对,2026-09-27 核实两站线上与本地产物**完全一致**。

## 更新了一处会误导的注释

脚本原注释写「设备网关白名单只放行 ls/stat/find/cat,打不了包」。
那是 waiter 白名单**硬编码 18 条**时的状况;2026-09-27 部署 1911575 后
106 已扩到 **22 条**(含 find/grep/sed/sort/tr/wc/head/tail/stat/file)。

**结论(打不了包)不变,但理由已变** —— 22 条里**没有 `tar`**,
有 `sed` 也不能打包。照旧文字理解会以为白名单只有 4 条。

## docs/zh/deploy-runbook.md 补 §5 站点章节

- 链路:`.60` nginx stream 按 ssl_preread SNI → 106:3080 → navi 容器内 nginx
  (**不是** portal-nginx,那个已 Exited 两周)
- 坑①:构建**必须**走 `tools/apidoc/build.sh`。裸跑 `mkdocs build` 会丢掉
  整个 `api/*.md` 和 `llms.txt` —— 而 `llms.txt` 正是给 agent 直读的入口。
  正确产物 106 个文件,裸跑只有 78 个
- 坑②:打包不能走设备网关(无 `tar`),必须 SSH 直连
- ★ **新增文档后必须更新 `mkdocs.yml` 的 nav** —— 没登记会被 mkdocs 明确
  警告 `not included in the nav configuration`,等于写完了但站点里不可达
- 验证要打**线上**而不是只看本地产物
- 回滚点与失败版本保留策略

写文档时我一度把 introduce 的域名写成「另见 §5.2」,但 §5.2 只讲了 SDK 的
SNI 链路 —— 已改为只写"同台同目录",并补上脚本里有依据的"故意不带 README.md"。
2026-09-27 23:00:04 +08:00
9640dad3b1 docs: 新增生产部署手册(homed / waiter)
`site-infra-runbook.md` 是**静态站 / nginx / 证书**的手册,不含 homed 与
waiter —— 两次生产部署(19:45 首次、21:50 修复)因此只存在于提交信息里,
查不到。

## 内容

**§1 homed**
- 硬前置:必须 `-tags=onnxruntime`(普通 build 只有 ~28MB,缺 ONNX Runtime)
- ★ **构建参数必须与线上一致**:不要顺手加 `-s -w`。加了产物从 86.8MB 掉到
  78MB,8.8MB 的差会让人误判成"构建坏了",而它只是被 strip 了
- `check` / `deploy` / `rollback` 三条命令与备份位置
- 部署后必核:`multimodal space active: provider=chineseclip` 才是 ONNX
  真加载起来的标志,缺它说明已降级但**不报错**
- ★ 适配器升级的保护语义(`.bundled` 三种情形的判定表),并记 21:50 那次
  实测:手工补过 `stream_index` 的 `openai.lua` 被正确判定为用户修改并保留
- 两次部署的真实数据对照表

**§2 waiter**
- 逐台更新、不可并行(两台连同一网关,同时重启会同时断链)
- 部署后确认 `device waiter-* online`
- 记 2026-09-27 顺手解决的悬案:106 此前无 `online` 而 30 正常,两台配置与
  token 完全相同 ⇒ 差异只可能在旧二进制,8月27日那版落在"未 bind 时收到
  ping 会关连接"的缺陷窗口
- `device_cmd_allowlist` 的替换语义、生效验证、幂等追加方法
- ★ 明确写**白名单只匹配命令名、不看参数**,`find -delete`/`sed -i` 仍能逃
  ⇒ **不要称它为"只读白名单"**

**§3 故障排查**:按"消息没反应 / 命令被拒 / 适配器异常 / 告警是否缺陷"
四条线各给命令;特别标注"群聊 not @bot"与"私聊没回"是两件事

**§4 已知未修**:三项目前是已知限制而非疏漏

文中数字均与现场核对:脚本子命令确实存在(`check`/`deploy`/`rollback`)、
生产白名单确为 22 条、两次二进制大小取自实际部署。
2026-09-27 22:25:58 +08:00
df51cd4705 fix(provider): has empty arguments 误报 —— 零参数工具被当成参数丢失
## 现象

部署后生产日志出现 14 次:

    provider.go:414  [provider:llmsproxy] tool_call seq_list (...) has empty arguments

## 根因

原始响应里参数**完好**(从日志扒出来):

    "tool_calls":[{"function":{"arguments":"{}","name":"clawhubadapter_list"},...}]

诊断条件是 `len(tc.Arguments)==0 && tc.RawArguments==""`,而
`parseToolArguments("{}")` 走 string 分支 → `json.Unmarshal("{}", &m)`
成功且 `m != nil`(**非 nil 的空 map**)⇒ 返回空 map ⇒ 命中告警。

被点名的全是**零参数工具**(`seq_list` / `*_list` / `seq_help`,
它们的 `properties` 本来就是 `{}`)。

## 为什么必须修

不是"日志吵"。这条诊断的本职是抓「上游/适配器**真的**把参数丢了」,
真发生时会被这 14 次噪音淹没 —— **诊断日志失去信噪比就等于没有**。

## 修法

新增 `argsLookDropped(rawArgs)`,判 `RawArguments` **原文**而非解析后的 map:

- 空串 / 纯空白 ⇒ 上游没给 arguments 键 ⇒ 真丢
- 能解析成 JSON(哪怕是 `{}`)⇒ 上游确实回了参数 ⇒ 不报
- 解析失败(如半截 JSON)⇒ 参数本身是坏的 ⇒ 等同丢失

## 判据(3 条)

- `TestEmptyArgumentsDiagnosticIgnoresExplicitEmptyObject`  4 个子用例:
  `{}` / ` { } ` / 完全缺失 / 只有空白
- `TestArgsLookDroppedIgnoresNonEmpty`  非空参数一律不报
- `TestArgsLookDroppedEndToEnd`  用**日志里出现过的真实 body** 走
  `normalizeOpenAIToolCalls` 到判定的完整接缝 —— 单测过了但接缝不对
  只有端到端抓得到

写判据时我先用错了类型:拿 `apiToolCall`(**非流式**路径的结构)喂
`normalizeOpenAIToolCalls`,vet 直接报错才纠正为 `openAIToolCall`。
两套结构并存,很容易接错缝。

## 顺带记录:另一个告警不是内核缺陷

`重复申请 stage 锁`(5 次)经排查是**插件侧**问题,内核自愈机制工作正常:

- `proc_main.go.tmpl:1569` SDK 模板在每个 stage handler 入口**自动**调
  `stage.lock`;`lock.go:51` 锁**不可重入** ⇒ 同一次 `before_toolcall`
  被触发两次且首次未释放就命中
- 已排除 qq 业务代码:`beforeToolcall`(plugin.go:1303-1350)只有
  `ctx.Lock()`(SDK **数据**锁,与 proc stage 锁是两把锁)与纯本地调用,
  无任何再次触发 stage 的路径
- 成因在插件进程侧运行时(编译进 9月14日的 `plugin.bin`,**不随 homed 部署**)
- 内核 `stage.go:102-106` 的强制释放是**有意设计**("锁仲裁回内核"自愈,
  实验 9),避免后续插件死锁;`stages.go:258` 把错误收进 `ctx.Errors`
  不中断流程 ⇒ 那轮 212 秒正常跑完

两条结论都写进文档,避免以后有人当内核缺陷去修。

门禁:`-race` 通过,`go test ./internal/... ./cmd/...` 全绿。
2026-09-27 21:18:32 +08:00
554d93cc7e docs: 两份 toolcall 文档对齐实现与部署实况
## 契约文档:状态头从「尚未实现」改为「已实现并部署」

生产已注册 7 个 `seq_*` 工具,但文档仍写着"设计定稿,尚未实现" ——
实现者(和读者)会以为 seq 还不存在。

新增 §9.3「实现落点」:设计稿 §8 写的是**六个** `seq_*` 工具,实现时
多了一个 `seq_when_call`(跨序列条件调用独立成工具,否则模型要手写
"先 seq_list 再挑目标再 seq_call",多一次往返且容易挑错),并记下三项
设计之外的修正(`seq_create` O(n²)、`Store.List()` 误认任意 `.json`、
存储用 AST 而非原始文本)。

同时点明:设计条款**仍是契约**,实现与本文冲突时以本文为准并修实现。

## 修一处预先存在的失效引用

§7 末尾 `见 §4.5` —— §4 只到 4.4,该小节不存在。改为按标题名引用
(`§4「结果契约」的 ErrToolNotFound 哨兵`):将来增删小节时不会再次失效。

自查脚本第一版把 8 个**存在**的章节误报成失效引用 —— 标题格式是
`## 1. 背景`(编号后跟 `.`),而我的正则要求编号后是空格。判据自己错了,
改成 `(\d+(?:\.\d+)*)\.?\s` 后才得到真实结果。

## 并行计划文档:部署小节 + 白名单专节

- 原「⚠ 部署前置条件(未完成)」改为「✅ 部署(已完成)」,补实际验证数据
- 记下"实例自述没有编排工具"不是说谎:生产二进制构建于 06:36、seq 引入
  于 `da8841e`(更晚)⇒ `strings | grep -c internal/plugins/seq` 为 0。
  这类"实例自述与代码状态不一致"应先查二进制构建时间,别急着怀疑提示词
- 新增「设备命令白名单改为可配置」:起因、替换语义、daemon 路径的疏漏
- 明确写下**已知局限**:白名单只匹配命令名、不看参数,
  `find -delete` / `sed -i` / `sort -o` 仍放行 ⇒ **不要把它叫"只读白名单"**,
  那会让人以为写操作被挡住了
- 记 106 此前无 `online` 日志的成因(旧 waiter 落在未 bind 时收 ping 会断连的
  缺陷窗口),以及 `ssh` 吃掉 `read` 输入导致"喂了 yes 却说已取消"

删掉了初稿里一段"反引号内 `+=` 写进 heredoc 导致赋值落到子 shell"的说法 ——
脚本与 git 历史里都没有这种写法,属凭记忆误记,不能留。

文中数字均与现场核对:seq 工具 7、二进制 86784400 / 12691402、白名单 22 条。
2026-09-27 20:00:33 +08:00
f693af3960 fix(deploy): deploy-waiter 的 ssh 加 -n,否则「喂了 yes 却说已取消」
`do_check` 里的 ssh 会从 stdin 读,把后续 `read -p "确认更新"` 的输入吃掉 ——
于是 `bash deploy-waiter.sh deploy <ip> <<< "yes"` 里的 yes 被 ssh 消耗,
read 拿到空串,脚本静默走「已取消」分支。

症状极难定位:脚本本身完全正常、备份逻辑没问题,只是"明明喂了 yes"却
什么也没发生。5 处 ssh 统一加 -n。
2026-09-27 19:52:53 +08:00
764d1b0dd3 chore(stress): 加 waiter 远程更新脚本;修 cmp.py 的 docstring 格式
## deploy-waiter.sh:106 / 30 的 waiter 更新

现状(更新前):两台都是 8月27日构建的 /opt/waiter/waiter(11,388,177 字节),
以 root 跑 waiter-remote.service,配置指向 ws://192.168.2.60:9890/…

安全设计:
- 先备份旧二进制(`$BIN.bak-<时间戳>`),失败即回滚(脚本内自动)
- **只换二进制,不动 waiter.yaml**(配置由 deploy 后单独追加)
- **逐台更新并验证,不并行** —— 两台都连同一网关,同时重启会同时断链
- 106 走 admin+sudo、30 走 root
- 验证项:服务 active + 进程时长 + **配置 md5 未变**

用法:`check`(只读)/ `deploy <ip>`(需输 yes)/ `rollback <ip>`

## cmp.py:两处格式

docstring 的 `"""` 紧贴内容、以及函数段之间缺两个空行(PEP8)。纯格式,
无逻辑改动。
2026-09-27 19:42:06 +08:00
1911575352 feat(waiter): 设备命令白名单改为 waiter.yaml 可配置
## 起因

白名单是源码里硬编码的正则(`homeagentAllowCmd`,18 个命令),
而 `waiter.yaml` 里**没有任何键能改它** ⇒ `find` / `grep` / `sed` / `sort` / `tr`
这些排查问题最常用的**只读**命令一律被拒。生产实测:

    device_ctl_cmdrun  device_id:waiter-fnnas  error: command not in whitelist

命令执行完全在 waiter 侧(`device.go` 的 `exec.CommandContext`),插件侧无二次
限制;触发者是 **agent**(经 device_ctl_cmdrun),所以这道闸是机器闸、不是人工确认。

## 改动

waiter.yaml 新增 `device_cmd_allowlist`(字符串数组):

    device_cmd_allowlist:
      - ls
      - find
      - grep
      - sed

- **替换**默认集而非追加:避免"以为加了 find、结果还留着 python3 -c 任意执行"
- 留空 ⇒ 用内置默认集(★ **绝不能变成"全放行"**,那等于静默拆掉闸门)
- 匹配只取命令名**第一段**再整词匹配:`grep -rn x .` 能过,
  而 `grepXxx` / `mygrep` 不会因 contains 蒙混过关;也跳过 `FOO=bar cmd` 的赋值前缀
- `deviceCmdAllowed` 是包级函数变量,由配置赋值 —— 与同文件既有的
  `sendBridgeResult` 同一模式

## ★ 一次真实的疏漏(判据记着)

waiter 有**两条**设备桥启动路径:
- `main.go` 的 `startDeviceBridge` —— 交互/一次性模式
- `daemon.go` 的 `startDaemonDeviceBridge` —— `waiter --daemon`(**生产两台都这么跑**)

我最初只在 `main.go` 里赋值。daemon 路径不经过那里 ⇒ 配置**完全不生效**,
而症状是"配置写了、启动也打了招呼、命令照样被拒",极难定位。
两处都接上了,并加 `TestDaemonPathAppliesAllowlist` 守住。

## 判据(5 条)

- `TestDefaultAllowlistStillBlocksDestructive`  默认集必须挡住
  `rm -rf /`、`dd`、`chmod -R 777`、`mkfs`、fork 炸弹 ——
  **这道闸存在的唯一理由**,谁把它改成"什么都不拦"这条就要失败
- `TestConfigAllowlistExtends`  配置里声明的 `find/grep/sed/sort/tr` 能过;
  配置未含的 `rm -rf /` 仍被拒(证明是"替换"不是"叠加")
- `TestEmptyConfigFallsBackToDefault`  配置为空时回退默认集,**且不放行** `rm -rf /`
- `TestCmdAllowlistFromYAML`  走**真实** `readFile` 解析 yaml(不另写一份解析,
  两处会漂移,而漂移本身就是漏洞)
- `TestDaemonPathAppliesAllowlist`  守住 daemon 路径也应用配置

## 生效方式

106/30 的 `/opt/waiter/waiter.yaml` 追加 `device_cmd_allowlist`,
并更新二进制。启动日志会打印 `device cmd allowlist: N 条(来自 waiter.yaml)`
或 `默认 N 条`,便于确认配置是否真的被读到。
2026-09-27 19:41:43 +08:00
a9fe741847 chore(deploy): 补强部署后验证
原来只说「等 20s 看 /kernel 状态」,等于没验。进程活着 ≠ agent 起来了。
现在逐项核对,每项对应一个真实故障模式:

- 60s 内未见 'kernel ready' ⇒ 判失败并给出回滚命令 + journalctl 尾部
- 注册工具条目数(生产应 18 个左右)⇒ 少了说明插件加载异常
- 'LLM API unreachable' 次数 > 3 ⇒ 内核在 rollback 循环
  (生产设了 max_retries=100000,不可达会一直重试)
- ONNX provider 相关日志 ⇒ 缺失时应是明确错误+降级,不静默假装启用
2026-09-27 19:12:12 +08:00
573f6bade7 chore(deploy): 加生产部署方案脚本(check/backup/deploy/rollback 四段)
## 为什么需要

生产二进制是 **`-tags=onnxruntime`** 构建(86.5MB,.rodata 62.5MB),
而普通 `go build` 只有 37MB —— 差的是 ONNX Runtime 绑定。
`deploy/packaging/package-linux.sh:139` 会显式拒绝非 onnxruntime 构建:

    if ! go version -m "$homed_bin" | grep -Eq 'build[[:space:]]+-tags=.*onnxruntime'; then
        echo "ERROR: homed 不是 onnxruntime 构建,拒绝打 server/full 包" >&2

也就是说:**用错构建方式部署,依存句法分析与多模态向量化会静默失效**。
这个坑我自己踩过一次(拿普通构建去比体积,才发现的),所以脚本第一步
就卡这个判据。

## 四个动作

- `check`    只读检查:服务状态、onnxruntime 标签、libonnxruntime.so、
              模型资产、适配器清单。**不改任何东西**,可随时跑
- `backup`   备份二进制 + 适配器 + unit 文件,并**生成 ROLLBACK.sh**
- `deploy`   check → 人工确认(输入 yes)→ 备份 → install -m 0755 原子替换
              → 重启 → 8 秒后验活;失败时打印回滚命令与 journalctl
- `rollback` 用最近一次备份回滚

## 刻意不做自动回滚

回滚要不要做、什么时候做,是人的判断。脚本只负责把状态保全好,
让回滚成为一条**可执行**的命令,而不是一个自动决策。

## 部署不会碰的东西(已在 check 里显式打印)

- **适配器文件**:`e7ec4c6` 的新逻辑在「无历史清单」时不动任何已存在的文件,
  所以生产的 10 个 .lua 保持原样(含那个已含 stream_index 的 openai.lua)
- **数据目录**:51G 的 models/ 与配置都不动,部署只换二进制
2026-09-27 19:11:41 +08:00
a103618ee7 docs(plan): 补记全面压测结果与部署前置条件
两版隔离实例实测(规模 3 = 288 条输入),核心差异是**处理数**而非耗时:
旧版 openai.lua 缺 stream_index 透传 ⇒ 多个分片并到槽 0、参数混拼 ⇒
工具一个都没真跑,却因为「少干活」而耗时更短。

    !slowbatch8   旧 0.220s / 0 个  →  新 0.409s / 8 个
    并发 vs 强制串行(新版内部)      N=8 加速 3.88×
    调度器轰炸 288 输入             两版均 100% 通过
    连续稳定性 20 轮                两版均无错误、内核无 panic

规模 1(32 条)与规模 3(288 条)结果完全一致 ⇒ 可复现。

同时记录部署前置条件:生产是 -tags=onnxruntime 构建(strip 后 75MB vs
普通构建 28MB),package-linux.sh:139 会显式拒绝非 onnxruntime 构建。
本次改动未触及任何 ONNX 路径,故压测结论对生产成立,但必须走
deploy/packaging/build.sh 才能部署。
2026-09-27 19:00:53 +08:00
146a71f11b docs(plan): 记录更新前后全面压测结果与部署前置条件
两版隔离实例实测(规模 3 = 288 条输入),核心差异是**处理数**而非耗时:
旧版 openai.lua 缺 stream_index 透传 ⇒ 多个分片并到槽 0、参数混拼 ⇒
工具一个都没真跑,却因为「少干活」而耗时更短。

    !slowbatch8   旧 0.220s / 0 个  →  新 0.409s / 8 个
    并发 vs 强制串行(新版内部)      N=8 加速 3.88×
    调度器轰炸 288 输入             两版均 100% 通过
    连续稳定性 20 轮                两版均无错误、内核无 panic

规模 1(32 条)与规模 3(288 条)结果完全一致 ⇒ 可复现。

同时记录部署前置条件:生产是 -tags=onnxruntime 构建(strip 后 75MB vs
普通构建 28MB),package-linux.sh:139 会显式拒绝非 onnxruntime 构建。
本次改动未触及任何 ONNX 路径,故压测结论对生产成立,但必须走
deploy/packaging/build.sh 才能部署。
2026-09-27 19:00:19 +08:00
6a8a4dc373 test(stress): 补更新前后全面对比脚本(cmp.py),修 blast 的计数错误
## cmp.py:更新前后对比的四个维度

★ 每轮都记录**处理数**(响应里有多少个工具结果标记)与**是否出现 error 帧**,
任一不符即记失败。理由:工具调用这一路的失败模式几乎都是**静默**的 ——
工具没跑、参数混拼、只处理了第一个 tool_call,都不报错只是结果不对。
"跑完没崩"完全不能说明它 work。

1. **批内工具调用**:!slowbatchN 在两版上各跑几轮,比中位耗时与处理数
2. **并发 vs 强制串行**(新版内部对照):!slowbatchN vs !serialbatchN
3. **调度器并发轰炸**:多连接并发排队,算通过率
4. **连续稳定性**:20 轮无错误率

## 修掉 mock 的 !serialbatch 缺失

之前只在 /tmp 的临时副本里加过,没进仓库,导致 cmp.py 测「强制串行」时
那个 marker 根本不存在 —— 测出来的"串行"其实是并发,**加速比是假的**
(0.34x / 0.31x,看起来并发比串行慢)。已加回并说明它的用途:跨版本做不了
并发/串行对照(旧版适配器缺 stream_index,工具一个都没真跑),只能在
同一套内核上做。

## 修掉 blast 的计数错误

第一版按 `conns * inputs` 起线程、每个线程又跑 `inputs` 轮 ⇒ 总输入数是
conns×inputs²,分子分母量纲不一致,算出过 **"128/32 = 400%"** 这种荒谬数字。

现在:恰好 conns 个 worker、每个跑 inputs 轮;且分母用**实际发出的**输入数
(含连接失败的),否则连接失败时通过率会虚高。

## 踩过的两个坑(都写进注释)

- 内核 `task.go:353` 有输入去重(`isDuplicateInput`,为 webui 断线重连重放
  而设),相同文本被丢弃并回空响应 ⇒ 每轮输入必须带唯一后缀
- cli 的 auth 帧本身就是 `{"type":"response"}` ⇒ 必须先吃掉它再开始收集,
  否则第一轮的"终止帧"是 auth,测出来耗时恒为 0
2026-09-27 18:56:25 +08:00
e7ec4c6b0a fix(lua): 内置适配器按内容自动更新,替代「文件已存在就跳过」
## 原机制是这次全部误判的根源

    if _, err := os.Stat(dstPath); err == nil { continue }

**升级二进制永远不更新已部署的适配器文件。** 于是"改了仓库 ≠ 生产生效",
而这个机制让同类问题可以长期潜伏:

    2026-08-26 15:46  生产 openai.lua 手工补上 stream_index 透传
    2026-08-26 16:10  cfd1653 提交,说明里写了但代码没改这个文件

修复当天先在生产落地、32 分钟后才提交入库(漏了这个文件),此后一个月里
两端都没人发现 —— 生产不报问题(它有),仓库的判据也测不到(直接构造 Go
结构体,绕过适配器)。而"升级不覆盖"意味着即使仓库补上修复,已部署的
老实例也不会拿到。

## 新判据(按内容,不按存在)

    文件不存在                     ⇒ 写
    有历史清单且盘上 == 上次内嵌   ⇒ 覆盖(只是没跟上新版本)
    有历史清单但盘上 != 上次内嵌   ⇒ 不动 + 日志(用户改过)
    无历史清单(首跑/从旧版本升级) ⇒ 不动,只补缺失文件(与旧行为一致)

"上次内嵌的版本"记在 `DataDir/adapters/.bundled`(`<name>\t<sha256>`)。

⚠️ 为什么不能无条件覆盖:adapter_path 是可配置项,用户可以把 adapter_path
指向自己维护的适配器。静默覆盖等于丢弃他们的修改,而且**没有报错**。

## 判据(两个方向都要测)

- TestWriteBundledAdaptersSkipsUserModified  用户改过的**必须保留**,
  且改完仍能正常加载(fixture 必须功能完整,否则会因为缺钩子函数而失败 ——
  那是 fixture 问题,不是保护逻辑问题)
- TestWriteBundledAdaptersUpdatesStale       落后于新内嵌的**必须被更新**,
  且**幂等**(三跑不再改写任何文件)

★ 第二条是必要的:只测保护的话,**一个"永远不覆盖任何文件"的实现也能全绿**
  —— 而那正是要修的病。

## 代价(必须知道)

**升级到本版本的这一次,已部署实例的适配器不会更新**(没有历史清单可比)。
从第二次升级起自动生效。要立刻生效就删掉 DataDir/adapters 让内核重新解包。

对本次修的 8 个适配器而言:生产此刻用不到(三个源 llmsproxy/visionllm/
justworker 全是 openai.lua),所以不影响运行;将来启用 deepseek 等源时,
自然就是修复版。

## 顺带

`bundledAdapterNames` 从 writeBundledAdapters 里提出来成包级变量 ——
writeBundledAdapters 与体检判据共用,避免两处各写一份而漏掉某个
(漏掉的后果是该适配器永远不会被更新)。
2026-09-27 18:46:51 +08:00
eb5e8fdceb fix(lua): 补齐 6 个适配器的流式 tool_calls 支持
体检判据(TestAllBundledAdaptersStreamToolCallStatus)报出的三类问题,
本提交解决其中两类;第三类(gemini)未动,原因见下。

## ① OpenAI 兼容族:github / groq / mistral(3 个)

它们的 transform_stream_chunk 与修复前的 deepseek **逐字相同** ——
只透 content/done,tool_calls 处理只存在于 transform_response(非流式)。

后果与 deepseek 相同:流式模式下工具调用全部丢失,模型调不动任何工具,
且**没有任何报错**。生产当前未启用这三个源,但按预设配置的用户会踩到。

照 deepseek 的修法补上(含 reasoning_content 透传)。

## ② 嵌套形态 + 键名错:server / kimicode / anthropic / ollama(4 个)

这四个**有** tool_calls 处理,但发的是:

    { index = N, id = ..., ["function"] = { name = ..., arguments = ... } }

而 homed 的 `agentAPI.ToolCall` 是**扁平**结构,json tag 为:

    id / type / name / arguments / raw_arguments / stream_index

两处都是**静默**失效(Go 侧按 json tag 反序列化,取不到就是零值,无报错):
- **嵌套** `["function"]` ⇒ `name` / `raw_arguments` 取零值
  ⇒ flush 时判「无 name」丢弃,或参数为空
- **键名 `index`** ⇒ `StreamIndex` 取零值
  ⇒ 多个分片并到同一个桶,argsRaw 混拼 ⇒ 每个工具报「参数不是合法 JSON」
  而**一个都没真跑**

已逐项对齐为扁平 + `stream_index`。协议差异都保留:
- anthropic:`content_block_start` / `input_json_delta`,续传片 name 留空
  (内核按 stream_index 累积,补齐 name 后才 flush)
- ollama:tool_calls **整条一次发完**(不分片),故 stream_index 取数组下标

## ③ gemini 未动

它的流式函数处理 `candidates[].content.parts`,**全文件没有任何
tool_calls / functionCall 处理** —— 连非流式路径也没有。补它不是"对齐"
而是新实现,且 gemini 的 functionCall 形态(`functionCall: {name, args}`,
args 是对象而非 JSON 字符串)与 OpenAI 族不同,需要单独判据。

生产三个源(llmsproxy / visionllm / justworker)全部用 `openai.lua`,
不阻塞。留作独立项。

## 判据

- TestOpenAICompatibleFamilyHandlesStreamToolCalls  5 个 OpenAI 族适配器,
  逐个验证 tool_calls 未丢 + stream_index 正确
- TestAnthropicAdapterEmitsFlatToolCallsWithStreamIndex  用 **Anthropic 协议**
  的 fixture(不用 OpenAI 的,否则会因"不适用该 chunk"跳过 —— 看着绿,
  实则没测)
- TestOllamaAdapterEmitsFlatToolCallsWithStreamIndex  用 Ollama 协议形态
- TestDeepSeekAdapterHandlesStreamToolCalls  单列,因它有源预设指向

★ 三个判据按**协议**分文件而非逐适配器:这几个文件的流式函数逐字相同,
共用一个 fixture 会因协议不适用而静默跳过 —— 那等于没测。

## 体检分类

    修前: ✓ [openai]        ⚠ [kimicode server]  ✗ [anthropic deepseek gemini github groq mistral ollama]
    修后: ✓ [openai deepseek github groq mistral]  ⚠ []  ✗ [gemini]
2026-09-27 18:34:33 +08:00
a6a025028d fix(lua): deepseek 适配器的流式路径处理 tool_calls(此前全部丢失)
## 缺陷

deepseek.lua 的 tool_calls 处理只存在于 `transform_response`(**非流式**路径),
而 `transform_stream_chunk` 只透 content/done:

    return json.encode({ content = delta.content or "", done = (fr ~= nil) })

于是 deepseek 源在**流式**模式下工具调用全部丢失 —— 模型调不动任何工具,
且**没有任何报错**,只是"工具好像不听话"。

## 为什么难发现

- 非流式路径是好的 ⇒ 端到端手工测试也过
- 内核的 tool call 循环默认走**流式**(provider.go 的 stream 分支)⇒ 实际不可用
- 功能判据(core 包的批内测试)直接构造 `agentAPI.StreamChunk{}`,
  **绕过适配器** ⇒ 测不到这一层

配置里 `deepseek` 源预设指向 `adapters/deepseek.lua`,所以任何按预设配置
的用户都会踩到(生产当前未启用该源,配置里 deepseek 相关键为 0)。

## 修法

照 openai.lua 的做法在流式路径补上:OpenAI 兼容格式
`{function:{name,arguments}, id, type, index}` → homed 扁平结构
`{id, type, name, raw_arguments, stream_index}`,含 reasoning_content 透传。

两个容易踩的点也写进注释:
- **不能按 name 过滤**:流式续传片 name 为空但携带 arguments,
  内核按 stream_index 分桶累积
- **必须透传 stream_index**:否则多个分片并到槽 0、argsRaw 混拼

## 判据

新增 TestDeepSeekAdapterHandlesStreamToolCalls:喂两个含 tool_call 的分片,
断言 tool_calls 未被丢弃且 stream_index 正确。修前两条分片全被丢弃。

## 体检分类随之变化

    修前: ✓ [openai]                                       ✗ [deepseek ...]
    修后: ✓ [deepseek openai]                              ✗ [anthropic gemini github groq mistral ollama]
2026-09-27 18:31:42 +08:00
92ada6e882 docs(stress): abtest 补「跨版本对比的陷阱」—— 老版本可能只是没干活
脚本原本只写「加速比 = 基线延迟 / 新版延迟」。实测发现跨版本对比时这个
数字**没有意义**:

    N    老版本 中位/处理数      新版本 中位/处理数      算出的"加速"
    2    0.273s / 0 个        0.480s / 2 个          0.57x
    4    0.273s / 0 个        0.492s / 4 个          0.55x
    8    0.273s / 0 个        0.512s / 8 个          0.53x

"老版本更快"是假的 —— 它只是没干活:适配器缺 stream_index 透传 ⇒ 多个
分片并到槽 0 ⇒ argsRaw 混拼 ⇒ 每个工具报"参数不是合法 JSON"而一个都没
真跑。

★ 跨版本能比的硬指标是**处理数**;耗时对比只在新版内部做(混一个非并发安全
工具触发整批降级)才有意义 —— 那种对照下 N=12 时加速 5.72×。

顺带记下:老版本的内核分桶逻辑其实完整(`idx := tc.StreamIndex` + `accs[idx]`,
来自 2026-08-26 的 cfd1653),缺的只是适配器那一个字段;而生产在当天 15:46
就手工补上了,比该提交(16:10)早 32 分钟。
2026-09-27 18:30:09 +08:00
0dd69a3433 test(stress): 补批内并发压测与 A/B 对比脚本,并给 mock 加多 tool_call 能力
## 缺口

现有 kernel-stress 只压**调度器**(多连接排队 + L4 中断 + 驻留子),mock 每轮
只发**一个** tool_call。而内核的并发判据是:

    if f == nil || len(f.PendingTools) <= 1 { return false }

⇒ **一个 tool_call 永远不并发**。所以现有压测压的全是串行路径,批内并发
一条都没走过。

## mockllm.py:让 mock 能发多个 tool_call

- `!batchN`   N 个全部 ParallelSafe 的只读工具 ⇒ 强制走 stepToolBatch
- `!mixedN`   夹一个 knowledge_create(未声明并发安全)⇒ 验证整批降级
- `!slowbatchN` N 个 sleep 型 cmd_run(单工具约 200ms,时长递增)
  ⇒ 工具慢才能让并发的收益从噪声里显出来;时长递增使**完成序与声明序相反**,
  可据此判定"是否按声明序落消息"
- `!serialbatchN` 在 !slowbatch 基础上插入一个非并发安全工具 ⇒ 强制整批串行,
  作为并发对照的另一半
- `!img` / `!ocr` 触发多模态工具路径(不加载模型,只验内核 IPC 接线与错误处理)
- `!err` / `!hang` 故障注入

### 三个协议细节(踩过才知道,都写在注释里)

1. **必须带 `index`** —— 内核按 `StreamIndex`(上游 JSON 的 index)分槽累积
   arguments。缺 index 时所有分片落到槽 0,几个 tool_call 的参数被**混拼**,
   症状是每个工具都报"参数不是合法 JSON"而工具一次没真跑过。
   单 tool_call 时不设 index 也正常,所以老 mock 一直没暴露。
2. **必须按协议分片** —— 一个 chunk 一个 tool_call,各自带 index;
   后续 chunk 只续 arguments。整数组塞进一个 chunk 会被内核按"续传"语义累积。
3. **每个工具都要发"带 name 的首片"** —— 我第一版只给第 0 个发首片、其余直接
   发续传片,看起来省事,但内核 flush 时按"无 name 即丢弃"处理,
   于是 idx=1/2/3 全被丢,只跑 1 个工具。

## batchstress.py:批内并发压测(带校验)

只看峰值是不够的 —— 校验:工具是否真跑(响应里应有结果标记)、
消息顺序是否稳定(并发执行但按索引落消息)、mixed 批是否整批降级。

## abtest.py:串行 vs 并发的定量对比

同一套内核上用 !slowbatch / !serialbatch 两组对照,交替执行抵消机器负载漂移,
取中位数(长尾会污染均值)。

★ 判据里加了"工具是否真执行"这一项:**只看耗时是不够的** —— 出现过
"内核 274ms 就回复、工具一个没跑"的情况,那种情况下并发与串行都是 0.2s,
加速比毫无意义。

## 实测(隔离 netns 实例,mock + 内核同网段,5 轮中位)

    N    并发        串行         加速     上限
    2    0.481s    0.685s      1.42x    2
    4    0.491s    1.114s      2.27x    4
    8    0.513s    2.037s      3.97x    8
    12   0.531s    3.039s      5.72x    12

并发批耗时几乎不随 N 增长,串行批严格线性。

★ 另一个踩过的坑(abtest 脚本自己的):内核有输入去重
(task.go:353 `isDuplicateInput`,为 webui 断线重连重放而设),相同文本会被
丢弃并回空响应。第一版每轮发同一个 marker ⇒ 只有第 1 轮有效,后面全是
0 秒 0 工具。修法是每轮加 `time.time_ns()` 唯一后缀。
2026-09-27 18:29:39 +08:00
c8ba1ddfef test(lua): 记录仓库/生产适配器漂移的具体内容与时间线
生产 adapters/openai.lua(4853 字节)含 stream_index,仓库 cfd1653 时的版本
(4709 字节)不含。生产文件时间 2026-08-26 15:46,比 cfd1653 提交(16:10)
早 32 分钟 —— 该提交说明里写着「openai.lua 输出 stream_index 字段」,
但 --stat 显示它没改这个文件:修复先在生产生效,入库时漏了。

于是「生产能跑多工具、仓库跑不了」持续一个月而两端都没人发现:生产不报
问题(它有),仓库的判据也测不到(直接构造 Go 结构体,绕过适配器)。

判据本身不变(仍是诊断式 t.Log),只把这条漂移的具体内容写进注释 ——
它是理解本次全部误判的关键背景。
2026-09-27 18:29:32 +08:00