From 41926e770db8a39ab382188ee6593eedcc57df30 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Wed, 11 Nov 2026 21:39:03 +0800 Subject: [PATCH] =?UTF-8?q?fix(lua):=20=E9=80=82=E9=85=8D=E5=99=A8?= =?UTF-8?q?=E6=B0=B8=E4=B9=85=E8=90=BD=E5=90=8E=20=E2=80=94=E2=80=94=20?= =?UTF-8?q?=E4=BF=AE=E6=AD=BB=E4=BB=A3=E7=A0=81=20+=20=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=E6=98=BE=E5=BC=8F=20opt-in=20=E5=90=8C=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 缺陷:适配器会永久卡在旧版 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- 后覆盖 零值 = off:忘记注入配置时宁可不动用户文件,也不能默默全量覆盖。 ## 告警要可操作,不是「保留用户版本」一句话 教训(2026-10-26 + 2026-11-11 两次):这类静默落后最难查,因为症状 指向模型而不是适配器。现在会给出:数量、名单、影响、三条处理路径 (跟随内核 / 长期定制改 adapter_path / 删掉旧文件)。 长期定制的正道是 `core.llm.sources..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- 可回滚 服务 active,NRestarts=0 --- internal/config/registry.go | 17 ++ internal/kernelmod/lua/mod.go | 11 + internal/lua/test/adapter_sync_test.go | 289 +++++++++++++++++++++++++ internal/lua/vm.go | 138 ++++++++---- 4 files changed, 413 insertions(+), 42 deletions(-) create mode 100644 internal/lua/test/adapter_sync_test.go diff --git a/internal/config/registry.go b/internal/config/registry.go index b88984cb..4bd86635 100644 --- a/internal/config/registry.go +++ b/internal/config/registry.go @@ -930,6 +930,23 @@ func (r *ConfigRegistry) seedCoreDefs(dataDir string, fresh bool) { reg(ConfigDef{Key: "core.llm.max_tokens", Default: "4096", Type: "int", DisplayName: "最大 Token", Description: "每次生成的最大 Token 数", Category: "llm"}) reg(ConfigDef{Key: "core.llm.thinking_enabled", Default: "false", Type: "bool", DisplayName: "深度思考", Description: "启用深度思考模式(如 DeepSeek R1 的思维链输出)", Category: "llm"}) + // ★ 适配器同步策略(2026-11-11)。 + // + // `adapters/` 下的 .lua 是**内核自带**的协议适配层,会随内核版本修复而变。 + // 但那是用户盘上的文件,内核该不该动它?默认**不动**(off)—— + // 擅自改用户文件比“落后一个版本”更糟,且无法区分“旧发行版残留” + // 与“用户手工改过”(两者在文件内容上长得一模一样)。 + // + // off 时:不覆盖,但**必须响亮告警**(告警做得不准等于没告警: + // 本机实测 8 个适配器落后 3000~6500 字节,日志只说“保留用户版本”, + // 完全看不出是落后)。 + // auto 时:差异即备份为 `.lua.bak-` 然后覆盖,永不会卡在旧版。 + reg(ConfigDef{Key: "core.lua.adapter_sync", Default: "off", Type: "string", + DisplayName: "适配器自动同步", + Description: "盘上适配器与内置版不一致时的处理:off(默认)只告警不改文件;auto 备份后自动覆盖。" + + "需要长期定制某个源,请改对应的 core.llm.sources..adapter_path,不要改内置同名文件。", + Category: "llm"}) + for name := range defaultSources { p := "core.llm.sources." + name reg(ConfigDef{Key: p + ".base_url", Default: defaultSources[name]["base_url"], Type: "string", DisplayName: name + " API 地址", Description: name + " LLM API 基础地址", Category: "sources"}) diff --git a/internal/kernelmod/lua/mod.go b/internal/kernelmod/lua/mod.go index 297e9c98..fbec5b01 100644 --- a/internal/kernelmod/lua/mod.go +++ b/internal/kernelmod/lua/mod.go @@ -21,6 +21,17 @@ func (Module) Provides() []string { return []string{kernelmod.FacLua} } func (Module) Init(ks *kstate.State) error { luaVM := luapkg.NewVM(filepath.Join(ks.Cfg.Daemon.DataDir, "adapters")) + // 适配器同步策略(core.lua.adapter_sync,默认 off)。 + // + // 读不到就保持零值 = off(保守):忘记注入时宁可「不动用户文件」, + // 也不能默默把用户的 adapters/ 全量覆盖一遍。 + if ks.CfgReg != nil { + if v, err := ks.CfgReg.Get("core.lua.adapter_sync"); err == nil && v != nil { + if s, ok := v.(string); ok { + luaVM.SetAdapterSync(s) + } + } + } if err := luaVM.Start(); err != nil { log.Printf("[homed] warning: lua vm init failed: %v", err) ks.LuaVM = luaVM diff --git a/internal/lua/test/adapter_sync_test.go b/internal/lua/test/adapter_sync_test.go new file mode 100644 index 00000000..1e4eb395 --- /dev/null +++ b/internal/lua/test/adapter_sync_test.go @@ -0,0 +1,289 @@ +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 差异即备份为 `.lua.bak-` 后覆盖,永不落后 +// +// 用户要长期定制有正道:`core.llm.sources..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) + } + } + } +} diff --git a/internal/lua/vm.go b/internal/lua/vm.go index 6499e874..abc7657d 100644 --- a/internal/lua/vm.go +++ b/internal/lua/vm.go @@ -162,12 +162,35 @@ type VM struct { mu sync.RWMutex dir string pools map[string]*adapterPool + + // adapterSync 决定盘上适配器与内置版不一致时怎么处理。 + // + // "" / "off"(默认):不覆盖,只告警。 + // "auto":备份为 .lua.bak- 后覆盖。 + // + // ★ 为何默认 off:adapters/ 在**用户的数据目录**里,内核擅自改它比 + // “落后一个版本”更糟;且“旧发行版残留”与“用户手工改过”在文件 + // 内容上长得一模一样,无法自动区分。要长期定制就配 adapter_path。 + adapterSync string } func NewVM(dir string) *VM { return &VM{dir: dir, pools: map[string]*adapterPool{}} } +// SetAdapterSync 设置适配器同步策略("off" / "auto")。由模块层从配置注入。 +// +// 未经注入时保持零值 = off(保守)。这条选择很重要:忘记注入时 +// 行为必须是“不动用户文件”,而不是“默默全量覆盖”。 +func (v *VM) SetAdapterSync(mode string) { v.adapterSync = strings.ToLower(strings.TrimSpace(mode)) } + +func (v *VM) AdapterSyncMode() string { + if v.adapterSync == "" { + return "off" + } + return v.adapterSync +} + func (v *VM) AdapterDir() string { return v.dir } func (v *VM) Start() error { @@ -645,9 +668,19 @@ func adapterVersion(src string) string { } func (v *VM) writeBundledAdapters() error { - prev := v.readBundledManifest() cur := map[string]string{} updated := 0 + mode := v.AdapterSyncMode() + + // drifted 收集「盘上与内置不一致」的适配器,用于末尾一条汇总告警。 + // 逐条打日志在 off 模式下会刷屏(本机一次 8 条),而人真正需要的是 + // 「有 N 个落后了 + 怎么处理」这一句。 + type driftInfo struct { + name string + disk string + bundled string + } + var drifted []driftInfo for _, name := range bundledAdapterNames { data, err := bundledAdapters.ReadFile("adapters/" + name + ".lua") @@ -657,51 +690,83 @@ func (v *VM) writeBundledAdapters() error { sum := sha256Hex(data) dstPath := filepath.Join(v.dir, name+".lua") + // ── 盘上已有:比对 hash ── + // + // ## 这里为什么不再用「清单里的 prevHash」判断 + // + // 旧实现拿上次清单做基线,认为「onDisk == prevHash ⇒ 没人动过 ⇒ 可覆盖」。 + // 但清单只存 hash,而「内置旧版本的 hash」与「用户改过的 hash」 + // 在清单里长得一模一样 —— 于是: + // + // 记了盘上那份当新基线 ⇒ 下次 onDisk == prevHash + // ⇒ 被误判成「没跟上新版本」⇒ 覆盖掉用户改动 + // 保留分支又因为末尾无条件 cur[name]=sum 把基线改回内置 hash + // ⇒ **永久**停在旧版,日志只说「保留用户版本」,看不出是卡住 + // + // 本机实测 8 个适配器中招(落后 3000~6500 字节,缺用量归一化、 + // 流式 tool_calls 等修复)。既然没法从文件本身区分这两种来路, + // 就**不猜**:默认一律不动(off),要动就由用户显式开 auto。 if old, rerr := os.ReadFile(dstPath); rerr == nil { onDisk := sha256Hex(old) - prevHash, known := prev[name] switch { - case !known: - // 无历史清单:无法判断是否被用户改过 ⇒ 不动(与旧行为一致)。 - // - // ★ 但这种"不动"必须**说出来**(2026-10-26 生产故障): - // 盘上是一份**早于本次功能修复**的适配器时,内核一声不响地继续用它。 - // 现场:deepseek.lua 的 transform_stream_chunk 缺 tool_calls 处理 - // ⇒ 流式模式下模型调不动任何工具、且零报错,症状看起来像"模型不听话"。 - // 仓库里的内置版本已修好,且 deepseek_stream_test.go 一直是绿的 —— - // 因为判据测的是**内置**文件,而运行时用的是**盘上**这份。 - if onDisk != sum { - fmt.Printf("[lua] WARNING: adapter %s on disk (v%s) differs from bundled (v%s) and has no recorded baseline; "+ - "NOT overwriting (user edits are preserved). If tool calls or streaming misbehave, this drift is the first suspect — "+ - "back up and delete %s to adopt the bundled version.\n", - name+".lua", adapterVersion(string(old)), adapterVersion(string(data)), name+".lua") - } case onDisk == sum: - // 已是当前版本,无需写 - case onDisk != prevHash: - // 与"上次内嵌"不同 ⇒ 用户改过 ⇒ 保留,并记下盘上真实版本 - fmt.Printf("[lua] adapter %s 已被修改,保留用户版本(内核不覆盖)\n", name+".lua") - cur[name] = onDisk - default: - // onDisk == prevHash != sum ⇒ 只是没跟上新版本,覆盖是安全的 + // 与内置一致,无需动作 + case mode == "auto": + // 显式允许:备份后覆盖。 + // 备份失败就不覆盖 —— 宁可保留旧版,也不能无声丢用户的改动。 + bak := dstPath + ".bak-" + onDisk[:8] + if werr := os.WriteFile(bak, old, 0644); werr != nil { + fmt.Printf("[lua] WARNING: adapter %s 与内置版不一致,但备份到 %s 失败(%v);"+ + "本次不覆盖,继续用盘上旧版。\n", name+".lua", filepath.Base(bak), werr) + cur[name] = onDisk + continue + } if werr := os.WriteFile(dstPath, data, 0644); werr != nil { return fmt.Errorf("write %s: %w", name, werr) } updated++ + fmt.Printf("[lua] adapter %s 与内置版不一致(v%s → v%s),已备份为 %s 并更新\n", + name+".lua", adapterVersion(string(old)), adapterVersion(string(data)), filepath.Base(bak)) + default: + // off(默认):不碰用户文件,但记下来后面汇总告警。 + drifted = append(drifted, driftInfo{name: name + ".lua", disk: onDisk, bundled: sum}) + cur[name] = onDisk } } else { + // 盘上没有:直接落盘(首次安装,不属于「改动用户文件」) if werr := os.WriteFile(dstPath, data, 0644); werr != nil { return fmt.Errorf("write %s: %w", name, werr) } updated++ fmt.Printf("[lua] installed bundled adapter: %s\n", name+".lua") } - cur[name] = sum + if _, set := cur[name]; !set { + cur[name] = sum + } + } + + // ★ off 模式的告警必须**可操作**。 + // + // 教训(2026-10-26 + 2026-11-11):这里曾经只打一句「保留用户版本」, + // 结果 8 个适配器落后内置版几个月都没人发现,而症状是「模型不听话」 + // 「工具调不动」这种完全指不到适配器的地方。 + // 所以告警要给出:数量、落后在哪个版本、以及一条能直接跑的命令。 + if len(drifted) > 0 { + names := make([]string, 0, len(drifted)) + for _, d := range drifted { + names = append(names, d.name) + } + fmt.Printf("[lua] WARNING: %d 个适配器与内置版不一致,当前保留盘上版本(core.lua.adapter_sync=off):\n", len(drifted)) + fmt.Printf("[lua] %s\n", strings.Join(names, ", ")) + fmt.Printf("[lua] 影响:盘上版本可能缺少内核已修的问题(用量统计、流式 tool_calls 等),\n" + + "[lua] 症状常表现为「模型不听话」或「工具调不动」,容易误判成模型问题。\n") + fmt.Printf("[lua] 处理:① 想跟随内核 ⇒ 设 core.lua.adapter_sync=auto 后重启(会自动备份旧版);\n"+ + "[lua] ② 想长期定制 ⇒ 把改过的文件改名,并配 core.llm.sources..adapter_path 指过去;\n"+ + "[lua] ③ 只是旧发行版残留 ⇒ 删掉 %s 下对应文件后重启即可。\n", v.dir) } if err := v.writeBundledManifest(cur); err != nil { - // 清单写失败只影响下次的判别,不该让启动失败 - fmt.Printf("[lua] 写内嵌清单失败(下次按不覆盖处理): %v\n", err) + fmt.Printf("[lua] 写内嵌清单失败(仅影响状态快照): %v\n", err) } if updated > 0 { fmt.Printf("[lua] updated %d bundled adapter(s)\n", updated) @@ -711,21 +776,10 @@ func (v *VM) writeBundledAdapters() error { func (v *VM) manifestPath() string { return filepath.Join(v.dir, ".bundled") } -// readBundledManifest 读上次运行时的内嵌清单(name → sha256)。 -func (v *VM) readBundledManifest() map[string]string { - out := map[string]string{} - b, err := os.ReadFile(v.manifestPath()) - if err != nil { - return out - } - for _, line := range strings.Split(string(b), "\n") { - parts := strings.SplitN(strings.TrimSpace(line), "\t", 2) - if len(parts) == 2 && parts[0] != "" { - out[parts[0]] = parts[1] - } - } - return out -} +// 注:原先还有一个 readBundledManifest(读上次清单做 prevHash 比对)。 +// 2026-11-11 重设计后不再需要它:现在的判断只看「盘上 hash 与内置 hash 是否相等」, +// 不依赖历史基线(那套基线无法区分「内置旧版」与「用户改过」, +// 正是导致 8 个适配器永久卡住的根源)。清单现在只作为**可读的状态快照**保留。 func (v *VM) writeBundledManifest(m map[string]string) error { var sb strings.Builder