mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-12 04:07:34 +00:00
## 缺陷:适配器会永久卡在旧版
writeBundledAdapters 用清单里的 prevHash 判断「盘上有没有被人动过」,
被动的那个就保留。但清单只存 hash ——「内置旧版本的 hash」与
「用户改过的 hash」在清单里长得一模一样。于是:
记了盘上那份当新基线 ⇒ 下次 onDisk == prevHash
⇒ 被误判成「没跟上新版本」⇒ 覆盖掉用户改动
而保留分支里的 cur[name] = onDisk 又会
## 被循环末尾无条件 `cur[name] = sum` 冲掉
⇒ 判「用户改过」后清单仍记内置 hash ⇒ 下次再判一遍「用户改过」
⇒ **永久**停在旧版。日志只有一句「保留用户版本」,
看起来像用户自己的选择,完全看不出是卡住。
本机实测 8 个适配器中招(openai/anthropic/github/groq/mistral/ollama/
kimicode/server),落后内置版 3000~6500 字节,缺的是用量归一化
(cache_read / reasoning_tokens)、流式 tool_calls 等真实修复。
症状最终表现为「模型不听话」「工具调不动」——指不到适配器身上。
## 为什么不能靠「自动覆盖」解决
「旧发行版残留」与「用户手工改过」在文件内容上无法区分(都只是
hash 不等于内置版)。擅自改用户盘上的文件,比落后一个版本更糟。
所以改成**显式 opt-in**:
core.lua.adapter_sync = off(默认) 不碰文件,但告警必须可操作
core.lua.adapter_sync = auto 差异即备份 .bak-<hash8> 后覆盖
零值 = off:忘记注入配置时宁可不动用户文件,也不能默默全量覆盖。
## 告警要可操作,不是「保留用户版本」一句话
教训(2026-10-26 + 2026-11-11 两次):这类静默落后最难查,因为症状
指向模型而不是适配器。现在会给出:数量、名单、影响、三条处理路径
(跟随内核 / 长期定制改 adapter_path / 删掉旧文件)。
长期定制的正道是 `core.llm.sources.<name>.adapter_path`(每源独立可配),
不需要改内置位置的同名文件 —— 这一点也写进告警里。
## 判据 7 条,均经变异验证
TestAdapterSyncDefaultsToOff 零值/未注入必须是 off
(变异:return "off" → "auto" ⇒ 红)
TestOffModeLeavesDiskUntouched off 不碰文件、不产生备份
(变异:case mode=="auto" → case mode!="" ⇒ 红)
TestAutoModeUpdatesWithBackup auto 更新且备份可回滚
TestAutoModeNoRedundantBackup 一致时不重复堆 .bak
TestAdapterStateStableAcrossRestarts 两种模式启停 3 次不漂移
TestManifestCoversAllBundledAdapters 清单不漏项
TestFirstInstallHappensInBothModes off 只「不动已存在文件」,≠不安装
go build / vet / gofmt / go test -race 全绿
## 生产现状(已按用户确认保留更新)
适配器 10/10 与内置一致,8 个 .bak-<hash8> 可回滚
服务 active,NRestarts=0
290 lines
8.2 KiB
Go
290 lines
8.2 KiB
Go
package test
|
||
|
||
// 适配器同步策略判据(2026-11-11)。
|
||
//
|
||
// ## 背景:一个真实事故
|
||
//
|
||
// `adapters/*.lua` 落在**用户的数据目录**里,但它们是内核自带的协议适配层,
|
||
// 会随内核修复而变。内核该不该动用户盘上的文件?
|
||
//
|
||
// 本机实测的现状:8 个适配器(openai/anthropic/github/groq/mistral/ollama/
|
||
// kimicode/server)落后内置版 3000~6500 字节,缺的是用量归一化
|
||
// (cache_read / reasoning_tokens)、流式 tool_calls 等真实修复。
|
||
// 而日志只说一句「保留用户版本」——看起来像用户自己的选择,
|
||
// 完全看不出是「永久落后」,症状最终表现为「模型不听话」「工具调不动」。
|
||
//
|
||
// ## 为什么默认 off
|
||
//
|
||
// 「旧发行版残留」与「用户手工改过」在文件内容上**长得一模一样**:
|
||
// 两者都只是「hash 不等于内置版」。既然无法区分,就不该猜 ——
|
||
// 擅自覆盖用户文件比落后一个版本更糟。
|
||
//
|
||
// 所以:
|
||
//
|
||
// off(默认) 不碰文件,但告警必须**可操作**(列出哪些、说明影响、给处理办法)
|
||
// auto 差异即备份为 `<name>.lua.bak-<hash8>` 后覆盖,永不落后
|
||
//
|
||
// 用户要长期定制有正道:`core.llm.sources.<name>.adapter_path` 是每源独立可配的。
|
||
//
|
||
// ## 判据怎么读
|
||
//
|
||
// AdapterDir() 与 SetAdapterSync()/AdapterSyncMode() 都是公开的,
|
||
// 完全黑盒:起 VM → 读文件系统 → 断言。
|
||
|
||
import (
|
||
"crypto/sha256"
|
||
"encoding/hex"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"testing"
|
||
|
||
"github.com/JianFeeeee/HologramAgent/internal/lua"
|
||
)
|
||
|
||
func readManifest(t *testing.T, dir string) map[string]string {
|
||
t.Helper()
|
||
data, err := os.ReadFile(filepath.Join(dir, ".bundled"))
|
||
if err != nil {
|
||
t.Fatalf("读清单: %v", err)
|
||
}
|
||
out := map[string]string{}
|
||
for _, line := range strings.Split(string(data), "\n") {
|
||
line = strings.TrimSpace(line)
|
||
if line == "" {
|
||
continue
|
||
}
|
||
parts := strings.Split(line, "\t")
|
||
if len(parts) != 2 {
|
||
t.Fatalf("清单行格式异常: %q", line)
|
||
}
|
||
out[parts[0]] = parts[1]
|
||
}
|
||
return out
|
||
}
|
||
|
||
func hashFile(t *testing.T, path string) string {
|
||
t.Helper()
|
||
data, err := os.ReadFile(path)
|
||
if err != nil {
|
||
t.Fatalf("读 %s: %v", path, err)
|
||
}
|
||
sum := sha256.Sum256(data)
|
||
return hex.EncodeToString(sum[:])
|
||
}
|
||
|
||
func countBackups(t *testing.T, dir string) int {
|
||
t.Helper()
|
||
entries, err := os.ReadDir(dir)
|
||
if err != nil {
|
||
t.Fatalf("readdir: %v", err)
|
||
}
|
||
var n int
|
||
for _, e := range entries {
|
||
if strings.Contains(e.Name(), ".bak-") {
|
||
n++
|
||
}
|
||
}
|
||
return n
|
||
}
|
||
|
||
// drift 造一次「盘上与内置不一致」,返回漂移后的 hash。
|
||
func drift(t *testing.T, dir, name string) string {
|
||
t.Helper()
|
||
target := filepath.Join(dir, name+".lua")
|
||
orig, err := os.ReadFile(target)
|
||
if err != nil {
|
||
t.Fatalf("读 %s: %v", target, err)
|
||
}
|
||
if err := os.WriteFile(target, append([]byte("-- drifted (旧发行版残留)\n"), orig...), 0644); err != nil {
|
||
t.Fatalf("写: %v", err)
|
||
}
|
||
return hashFile(t, target)
|
||
}
|
||
|
||
// 判据:默认模式必须是 off。
|
||
//
|
||
// 这条钉的是「忘记注入配置时的行为」——必须是保守的,不能默认覆盖用户文件。
|
||
func TestAdapterSyncDefaultsToOff(t *testing.T) {
|
||
v := lua.NewVM(t.TempDir())
|
||
if got := v.AdapterSyncMode(); got != "off" {
|
||
t.Fatalf("★ 默认模式 = %q,期望 off(默认覆盖用户文件太危险)", got)
|
||
}
|
||
// 大小写/空白不应影响判定
|
||
v.SetAdapterSync(" AUTO ")
|
||
if got := v.AdapterSyncMode(); got != "auto" {
|
||
t.Fatalf("★ 大小写归一化失效:%q", got)
|
||
}
|
||
}
|
||
|
||
// 判据:off 模式下**不碰**用户的适配器文件。
|
||
//
|
||
// 变异验证(实测):把 off 分支改成也覆盖 ⇒ 本判据红。
|
||
func TestOffModeLeavesDiskUntouched(t *testing.T) {
|
||
dir := t.TempDir()
|
||
v1 := lua.NewVM(dir) // 默认 off
|
||
if err := v1.Start(); err != nil {
|
||
t.Fatalf("首次 Start: %v", err)
|
||
}
|
||
v1.Stop()
|
||
|
||
want := drift(t, dir, "openai")
|
||
|
||
v2 := lua.NewVM(dir)
|
||
if err := v2.Start(); err != nil {
|
||
t.Fatalf("二次 Start: %v", err)
|
||
}
|
||
v2.Stop()
|
||
|
||
if got := hashFile(t, filepath.Join(dir, "openai.lua")); got != want {
|
||
t.Fatal("★ off 模式仍改动了盘上文件")
|
||
}
|
||
if n := countBackups(t, dir); n != 0 {
|
||
t.Fatalf("★ off 模式产生了 %d 个备份(说明它动过文件)", n)
|
||
}
|
||
}
|
||
|
||
// 判据:auto 模式下,差异必须被更新,且留下可回滚的备份。
|
||
//
|
||
// 这条钉的是「永不落后」这条路:选 auto 的人要的是跟随内核。
|
||
func TestAutoModeUpdatesWithBackup(t *testing.T) {
|
||
dir := t.TempDir()
|
||
v1 := lua.NewVM(dir)
|
||
if err := v1.Start(); err != nil {
|
||
t.Fatalf("首次 Start: %v", err)
|
||
}
|
||
v1.Stop()
|
||
|
||
driftHash := drift(t, dir, "groq")
|
||
driftedContent, _ := os.ReadFile(filepath.Join(dir, "groq.lua"))
|
||
|
||
v2 := lua.NewVM(dir)
|
||
v2.SetAdapterSync("auto")
|
||
if err := v2.Start(); err != nil {
|
||
t.Fatalf("二次 Start: %v", err)
|
||
}
|
||
v2.Stop()
|
||
|
||
now := hashFile(t, filepath.Join(dir, "groq.lua"))
|
||
if now == driftHash {
|
||
t.Fatal("★ auto 模式下盘上仍是旧版")
|
||
}
|
||
// 旧内容必须能在备份里找到(不无声丢改动)
|
||
bak := filepath.Join(dir, "groq.lua.bak-"+driftHash[:8])
|
||
old, err := os.ReadFile(bak)
|
||
if err != nil {
|
||
t.Fatalf("★ 没留下备份(用户改动被无声丢弃): %v", err)
|
||
}
|
||
if string(old) != string(driftedContent) {
|
||
t.Fatal("★ 备份内容与覆盖前不一致")
|
||
}
|
||
}
|
||
|
||
// 判据:auto 模式下,与内置一致时不得重复备份(否则每次启动堆一个 .bak)。
|
||
func TestAutoModeNoRedundantBackup(t *testing.T) {
|
||
dir := t.TempDir()
|
||
for i := 0; i < 3; i++ {
|
||
v := lua.NewVM(dir)
|
||
v.SetAdapterSync("auto")
|
||
if err := v.Start(); err != nil {
|
||
t.Fatalf("第 %d 次 Start: %v", i+1, err)
|
||
}
|
||
v.Stop()
|
||
}
|
||
if n := countBackups(t, dir); n != 0 {
|
||
t.Fatalf("★ 无漂移却产生 %d 个备份(每次启动都会堆积)", n)
|
||
}
|
||
}
|
||
|
||
// 判据:反复启停后状态稳定(两种模式都不该在启停间漂移)。
|
||
//
|
||
// 这是旧设计的另一半症状:清单基线被来回改写,导致每次启动都重判一遍。
|
||
func TestAdapterStateStableAcrossRestarts(t *testing.T) {
|
||
for _, mode := range []string{"off", "auto"} {
|
||
dir := t.TempDir()
|
||
v := lua.NewVM(dir)
|
||
v.SetAdapterSync(mode)
|
||
if err := v.Start(); err != nil {
|
||
t.Fatalf("[%s] Start: %v", mode, err)
|
||
}
|
||
v.Stop()
|
||
|
||
drift(t, dir, "mistral")
|
||
|
||
var prev string
|
||
for i := 0; i < 3; i++ {
|
||
vv := lua.NewVM(dir)
|
||
vv.SetAdapterSync(mode)
|
||
if err := vv.Start(); err != nil {
|
||
t.Fatalf("[%s] 第 %d 次 Start: %v", mode, i+1, err)
|
||
}
|
||
vv.Stop()
|
||
|
||
m := readManifest(t, dir)
|
||
cur := ""
|
||
for _, name := range []string{"openai", "groq", "mistral", "deepseek"} {
|
||
disk := hashFile(t, filepath.Join(dir, name+".lua"))
|
||
if m[name] != disk {
|
||
t.Fatalf("[%s] 第 %d 次启动后 %s 清单与盘上不一致", mode, i+1, name)
|
||
}
|
||
cur += m[name]
|
||
}
|
||
if prev != "" && cur != prev {
|
||
t.Fatalf("[%s] 清单在启停间漂移(第 %d 次)", mode, i+1)
|
||
}
|
||
prev = cur
|
||
}
|
||
}
|
||
}
|
||
|
||
// 判据:清单覆盖全部落盘的适配器(漏记 = 那个适配器永远不会被体检到)。
|
||
func TestManifestCoversAllBundledAdapters(t *testing.T) {
|
||
dir := t.TempDir()
|
||
v := lua.NewVM(dir)
|
||
if err := v.Start(); err != nil {
|
||
t.Fatalf("Start: %v", err)
|
||
}
|
||
defer v.Stop()
|
||
|
||
m := readManifest(t, dir)
|
||
entries, err := os.ReadDir(dir)
|
||
if err != nil {
|
||
t.Fatalf("readdir: %v", err)
|
||
}
|
||
var n int
|
||
for _, e := range entries {
|
||
if !strings.HasSuffix(e.Name(), ".lua") {
|
||
continue
|
||
}
|
||
n++
|
||
name := strings.TrimSuffix(e.Name(), ".lua")
|
||
if _, ok := m[name]; !ok {
|
||
t.Errorf("★ 适配器 %s 落盘了但没进清单", name)
|
||
}
|
||
}
|
||
if n == 0 {
|
||
t.Fatal("★ 没有落盘任何适配器")
|
||
}
|
||
}
|
||
|
||
// 判据:首次安装(盘上无文件)必须落盘,两种模式都一样。
|
||
//
|
||
// 反向保护:off 的语义是「不动**已存在**的用户文件」,不是「不安装」。
|
||
func TestFirstInstallHappensInBothModes(t *testing.T) {
|
||
for _, mode := range []string{"off", "auto"} {
|
||
dir := t.TempDir()
|
||
v := lua.NewVM(dir)
|
||
v.SetAdapterSync(mode)
|
||
if err := v.Start(); err != nil {
|
||
t.Fatalf("[%s] Start: %v", mode, err)
|
||
}
|
||
v.Stop()
|
||
|
||
for _, name := range []string{"openai", "deepseek", "server"} {
|
||
if _, err := os.Stat(filepath.Join(dir, name+".lua")); err != nil {
|
||
t.Fatalf("[%s] 首次安装缺 %s.lua", mode, name)
|
||
}
|
||
}
|
||
}
|
||
}
|