From 6067f85c039f67b30ba9c73d2bf923103287a8ae Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sun, 25 Oct 2026 20:15:43 +0800 Subject: [PATCH 1/5] =?UTF-8?q?fix(memory):=20graph=5Fcentroid=20=E5=BB=BA?= =?UTF-8?q?=E8=A1=A8=E5=BD=92=E4=BD=8D=E5=88=B0=20schemas=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20=E6=96=B0=E5=BA=93=E4=B8=8D=E5=86=8D=E6=AF=8F?= =?UTF-8?q?=E8=BD=AE=E5=9B=9E=E9=80=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题(WSL2 真机诊断报告问题 5):新库上每轮召回都打 `[graph] recall blocks: reading centroid failed, falling back to raw vectors: graph: load centroid: no such table: graph_centroid` 417KB 日志里占相当比例,并回退到全量 raw vectors 扫描(生产库 3178 块)。 根因不是"迁移脚本漏了",而是**建表根本没在初始化里**: graph_centroid 只定义在 centroid_store.go,且只由 SaveCentroid 内部的 ensureCentroidSchema 创建。新建的库在存过第一个中心向量之前**从不** 调 SaveCentroid ⇒ 建表语句一次没跑过;而召回走的是 LoadCentroid。 修法:把 ddlCentroid 加进 graph.go 的 schemas 列表(复用同一个常量, 避免两处 DDL 漂移)。 判据(centroid_store_test.go,2 条,均经变异验证): · TestGraphCentroidTableExistsOnNewDB —— 新建库后表必须已存在 · TestLoadCentroidOnNewDBDoesNotFail —— 空库 LoadCentroid 返回 (nil,false,nil) 而不是 error(贴真实故障路径) 变异:删掉 schemas 里那行 ddlCentroid ⇒ 两条全红,报错文案与真机一致。 --- internal/memory/centroid_store_test.go | 51 ++++++++++++++++++++++++++ internal/memory/graph.go | 10 +++++ 2 files changed, 61 insertions(+) diff --git a/internal/memory/centroid_store_test.go b/internal/memory/centroid_store_test.go index 1ec5316d..6a02a282 100644 --- a/internal/memory/centroid_store_test.go +++ b/internal/memory/centroid_store_test.go @@ -381,3 +381,54 @@ func putRawBlock(t *testing.T, g *GraphDB, fp, id string, v []float64) { t.Fatal(err) } } + +// ── 2026-10-25:graph_centroid 必须随库创建,不能等第一次 SaveCentroid ── +// +// 缺陷现场(WSL2 真机诊断报告问题 5):新建库上每轮召回都打 +// +// `[graph] recall blocks: reading centroid failed, falling back to raw vectors: +// graph: load centroid: no such table: graph_centroid` +// +// 417KB 日志里占相当比例,并回退到全量 raw vectors 扫描。 +// +// 根因:buildSchemas 的 schemas 列表里漏了 graph_centroid,它只在 +// SaveCentroid 内部被 ensureCentroidSchema 创建——而新库在存过第一个 +// 中心向量之前从不调 SaveCentroid。 +// +// 判据刻意**不先调 SaveCentroid**:只要一存就会建表,那样就盖住了缺陷 +// (变异验证:把 graph.go 里的 ddlCentroid 那行删掉,本判据必须变红)。 +func TestGraphCentroidTableExistsOnNewDB(t *testing.T) { + g := newTestGraph(t) + defer g.Close() + + var name string + err := g.db.QueryRow( + `SELECT name FROM sqlite_master WHERE type='table' AND name='graph_centroid'`).Scan(&name) + if err != nil { + t.Fatalf("新建库应已有 graph_centroid 表,实际查询失败: %v", err) + } + if name != "graph_centroid" { + t.Fatalf("表名不符: %q", name) + } +} + +// 判据二:新建库上直接 LoadCentroid 不得报错。 +// +// 这条贴着**真实故障路径**(召回时调 LoadCentroid),比只查 sqlite_master +// 更接近用户看到的现象:查表不存在会以 error 形式冒出来。 +func TestLoadCentroidOnNewDBDoesNotFail(t *testing.T) { + g := newTestGraph(t) + defer g.Close() + + // 空库查询:应当"查不到"(nil, false)而不是"表不存在"(error)。 + c, anomalous, err := g.LoadCentroid("any-fingerprint", 512) + if err != nil { + t.Fatalf("空库 LoadCentroid 不应报错(表必须已存在),实际: %v", err) + } + if c != nil { + t.Fatalf("空库不应返回中心向量,实际: %+v", c) + } + if anomalous { + t.Fatal("空库不应标记 anomalous") + } +} diff --git a/internal/memory/graph.go b/internal/memory/graph.go index ce55b50b..c9231e05 100644 --- a/internal/memory/graph.go +++ b/internal/memory/graph.go @@ -408,6 +408,16 @@ func (g *GraphDB) initSchema() error { `CREATE INDEX IF NOT EXISTS idx_relation_type ON relations(relation_type)`, `CREATE INDEX IF NOT EXISTS idx_relation_status ON relations(status)`, `CREATE INDEX IF NOT EXISTS idx_relation_session ON relations(session_id)`, + // ★ graph_centroid 必须在这里建(2026-10-25 修缺陷)。 + // + // 此前它只由 SaveCentroid 内部的 ensureCentroidSchema 创建, + // 而新建的库在存过第一个中心向量之前**从不**调 SaveCentroid。 + // 后果:LoadCentroid 每次召回都 `no such table: graph_centroid`, + // 于是回退到全量 raw vectors 扫描——日志刷屏,且大库上明显变慢 + // (实测生产库 3178 块)。 + // + // 复用 centroid_store.go 的同一个常量,避免两处 DDL 漂移。 + ddlCentroid, } for _, s := range schemas { From f833897d40f7c4becf6f006a8da878b5cb58a6ef Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sun, 25 Oct 2026 20:16:06 +0800 Subject: [PATCH 2/5] =?UTF-8?q?fix(agentcli):=20=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E6=8A=8A=E8=87=AA=E9=80=A0=E7=8A=B6=E6=80=81=E5=BD=93=E6=96=B0?= =?UTF-8?q?=E4=BA=8B=E4=BB=B6=E6=B3=A8=E5=85=A5=20=E2=80=94=E2=80=94=20?= =?UTF-8?q?=E6=AF=8F=E6=9D=A1=E9=83=BD=E7=83=A7=E4=B8=80=E6=95=B4=E8=BD=AE?= =?UTF-8?q?=E6=8E=A8=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题(WSL2 真机诊断报告问题 4,影响最大):开终端注入「已启动」、 超时注入「已超时关闭」,每条都唤醒一整轮 LLM 推理。实测后果是 agent 反复向用户解释"终端怎么又没了",真正要做的活一件没做。 这属于本插件**自身逻辑矛盾**,不是内核缺陷: 1. 「已启动」是与 terminal_create 返回值的重复告知。返回值已含 id/command/timeout/rows/cols/notify_mode,信息完整。 2. 「已超时关闭」在创建时已由 timeout 字段告知,agent 本可推导; 且注入后紧接着 removeSession,agent 去 terminal_list 查必然是 0 个, 只能盲目排查——而这是插件自己删的。 3. 同一函数里 IsExpired() 语义相反:r.err 分支把它列为**必须静默**, 而超时分支却在注入。 4. 本插件自己的 notify=exit/interval/buffer 参数本就定义了"什么该通知"。 两处无条件注入是不经该配置的旁路,与自身设计相矛盾。 顺带:r.err 不再以 %v 原文进 prompt(原来注入 `read /dev/ptmx: input/output error`,把 agent 引去查 devpts 挂载, 好几轮推理花在这上面)。改为给**结论**「读取结束」,原始错误只进日志。 判据(plugin_test.go): · 新增 2 条**反向判据** TestNoStartupNotificationInjected / TestNoExpiredNotificationInjected —— 没有它们,后人把注入加回来时 其它测试依然全绿,缺陷会静默复发 · 原 7 处用「等已启动通知」做时序同步的测试改为 waitReadLoopReady (轮询 mockTerm.readCalls(),不靠固定 sleep 也不依赖业务通知) ★ 保留不动:进程自己结束/读取错误的通知(agent 无法从工具返回推导), 由 watch 配置控制。判据 TestProcessExit_StillNotifies 钉住这一点。 --- internal/plugins/agentcli/plugin.go | 47 +++++-- internal/plugins/agentcli/plugin_test.go | 169 +++++++++++++++++++---- 2 files changed, 183 insertions(+), 33 deletions(-) diff --git a/internal/plugins/agentcli/plugin.go b/internal/plugins/agentcli/plugin.go index 06dedd56..a2556224 100644 --- a/internal/plugins/agentcli/plugin.go +++ b/internal/plugins/agentcli/plugin.go @@ -995,11 +995,19 @@ func (p *Plugin) readLoop(t *TerminalSession, s *sdk.PluginSDK) { defer emitTermState(s, t, false) defer flushTermStream(s, t) - // 立即发送首次"终端已启动"通知,让 agent 感知存在。 - // 用 NoMemory:这是状态提示,不是对话内容。不关掉的话每开一个终端都会 - // 在记忆里留下一条"[终端 X 已启动]",把真实内容挤掉。 - s.InjectTextOpts("agentcli", "agentcli", fmt.Sprintf("[终端 %s 已启动]", t.id), - sdk.InjectOptions{NoMemory: true}) + // ★ 不注入「终端已启动」通知(2026-10-25 移除)。 + // + // 理由:terminal_create 的**返回值本身**已经告知 agent 终端已建立, + // 且带 id/command/timeout/rows/cols/notify_mode —— 信息完整。 + // 再用「用户输入」的形式注入一遍,等于同一条信息告知两次, + // 而每次注入都会唤醒**一整轮 LLM 推理**。 + // + // 实测代价(WSL2 真机):开一个终端 → 一轮推理;关一个 → + // 又一轮。agent 反复向用户解释"终端怎么又没了",真正要做的活一件没做。 + // + // 设计依据(本插件自己的 notify 参数):什么该通知,本来就由 agent + // 在创建时通过 notify=exit/interval/buffer 指定。无条件旁路注入 + // 与本插件自身的设计相矛盾。 now := time.Now() t.mu.Lock() t.lastNotify = now @@ -1023,9 +1031,18 @@ func (p *Plugin) readLoop(t *TerminalSession, s *sdk.PluginSDK) { } if t.IsExpired() { - log.Printf("[agentcli] terminal %s expired after %v", t.id, t.timeout) - s.InjectTextOpts("agentcli", "agentcli", fmt.Sprintf("[终端 %s 已超时关闭(%s)]", t.id, t.timeout), - sdk.InjectOptions{NoMemory: true}) + // ★ 只记日志,**不注入**(2026-10-25 修正)。 + // + // 两条理由: + // ① 超时时长已在创建时由 terminal_create 的 timeout 字段告知, + // agent 完全能推导"它 5 分钟后会自己关"——再通知没有新信息。 + // ② 注入后紧接着 removeSession,agent 去 terminal_list 查必然是 + // 0 个,于是只能一遍遍排查"终端为什么消失",而这是插件自己删的。 + // + // 注意与下方 r.err 分支的一致性:那里已经把「超时清理」列为 + // **必须静默**;同一函数内同一个 IsExpired() 不能一处静默一处注入。 + log.Printf("[agentcli] terminal %s expired after %v (silent: timeout already reported at create)", + t.id, t.timeout) p.removeSession(t.id) return } @@ -1096,7 +1113,19 @@ func (p *Plugin) readLoop(t *TerminalSession, s *sdk.PluginSDK) { if p.isClosing(t) || t.IsExpired() { return // 主动关闭 / 超时清理:不注入 } - s.InjectTextOpts("agentcli", "agentcli", fmt.Sprintf("[终端 %s 读取结束: %v]", t.id, r.err), + // ★ 不把 r.err 原文灌进 prompt(2026-10-25 修正)。 + // + // 原来注入的是 `[终端 X 读取结束: read /dev/ptmx: input/output error]`。 + // agent 会把它当成需要排查的技术问题,花好几轮去查 + // /dev/ptmx 是否存在、devpts 挂载是否正常——虽然结论是对的 + // (EIO 是 pty 主端关闭后 read 的正常返回),但这根本不该 + // 成为 agent 的活,而且每一轮查询都在烧上下文。 + // + // 改为:给**结论**(进程已结束)而不是**机制**(什么 errno)。 + // 原始错误只进日志,需要时人工看得到。 + log.Printf("[agentcli] terminal %s read ended: %v", t.id, r.err) + s.InjectTextOpts("agentcli", "agentcli", + fmt.Sprintf("[终端 %s 读取结束]", t.id), sdk.InjectOptions{NoMemory: true}) return } diff --git a/internal/plugins/agentcli/plugin_test.go b/internal/plugins/agentcli/plugin_test.go index 41ea7a3a..c47b95ba 100644 --- a/internal/plugins/agentcli/plugin_test.go +++ b/internal/plugins/agentcli/plugin_test.go @@ -480,6 +480,16 @@ type mockTerm struct { data chan []byte running bool err error + // reads 记录 Read 被调用次数,供 waitReadLoopReady 判断 readLoop 已起来。 + // 不用固定 sleep:机器慢时 sleep 不够、快时白等。 + reads int +} + +// readCalls 返回 Read 被调用次数(并发安全)。 +func (m *mockTerm) readCalls() int { + m.mu.Lock() + defer m.mu.Unlock() + return m.reads } func newMockTerm() *mockTerm { @@ -487,6 +497,9 @@ func newMockTerm() *mockTerm { } func (m *mockTerm) Read(buf []byte) (int, error) { + m.mu.Lock() + m.reads++ + m.mu.Unlock() for { m.mu.Lock() err := m.err @@ -517,6 +530,7 @@ func (m *mockTerm) Running() bool { defer m.mu.Unlock() return m.running } + // Kill 模拟真实 PTY 的行为:把一份 **buffer 里的输出 + EIO** 交给 Read。 // // 两点必须还原,否则测不出线上的回声: @@ -570,6 +584,26 @@ func startReadLoop(p *Plugin, s *sdk.PluginSDK, t *TerminalSession) { go p.readLoop(t, s) } +// waitReadLoopReady 等 readLoop 真正进入工作状态。 +// +// ★ 为什么需要它:原测试用「已启动」注入当同步点(等那条通知出现再继续)。 +// 但 2026-10-25 起该注入已移除(它是"同一条信息告知两次",每条都会唤醒 +// 一整轮推理)。同步点因此需要换成不依赖注入的方式。 +// +// 这里用「mockTerm 的 Read 至少被调用过一次」作为信号:readLoop 的第一件事 +// 就是 Read,比固定 sleep 可靠,也不引入对业务通知的依赖。 +func waitReadLoopReady(t *testing.T, term *mockTerm) { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if term.readCalls() > 0 { + return + } + time.Sleep(2 * time.Millisecond) + } + t.Fatal("readLoop 未在 2s 内进入读取状态") +} + func waitInjected(c *injectCapture, substr string, timeout time.Duration) bool { deadline := time.Now().Add(timeout) for time.Now().Before(deadline) { @@ -602,9 +636,9 @@ func TestReadLoopNotifyThrottle(t *testing.T) { ts := newTestSession(term) startReadLoop(p, sdkInst, ts) - if !waitInjected(capture, "已启动", 2*time.Second) { - t.Fatal("expected startup notification") - } + // 等 readLoop 真正起来(不再依赖"已启动"注入:那已被移除, + // 理由见 plugin.go 中"不注入终端已启动通知"的说明) + waitReadLoopReady(t, term) // 持续以 100B/50ms(=2KB/s) 吐进度 3 秒 stop := make(chan struct{}) @@ -666,9 +700,9 @@ func TestReadLoopNotifyOnExit(t *testing.T) { ts := newTestSession(term) startReadLoop(p, sdkInst, ts) - if !waitInjected(capture, "已启动", 2*time.Second) { - t.Fatal("expected startup notification") - } + // 等 readLoop 真正起来(不再依赖"已启动"注入:那已被移除, + // 理由见 plugin.go 中"不注入终端已启动通知"的说明) + waitReadLoopReady(t, term) term.setRunning(false) gotExit := waitInjected(capture, "进程已退出", 2*time.Second) @@ -698,9 +732,9 @@ func TestReadLoopNotifyOnReadError(t *testing.T) { ts := newTestSession(term) startReadLoop(p, sdkInst, ts) - if !waitInjected(capture, "已启动", 2*time.Second) { - t.Fatal("expected startup notification") - } + // 等 readLoop 真正起来(不再依赖"已启动"注入:那已被移除, + // 理由见 plugin.go 中"不注入终端已启动通知"的说明) + waitReadLoopReady(t, term) term.setErr(fmt.Errorf("read timeout")) if !waitInjected(capture, "读取结束", 3*time.Second) { @@ -740,13 +774,13 @@ func TestClose_NoEchoNotificationsAfterClose(t *testing.T) { }) sdkInst.SetIOInjector(capture) - ts := newTestSession(newMockTerm()) + mterm := newMockTerm() + ts := newTestSession(mterm) startReadLoop(p, sdkInst, ts) - // 等"已启动"到达:这是**正常**通知(终端确实刚创建)。 - if !waitInjected(capture, "已启动", 2*time.Second) { - t.Fatal("expected startup notification") - } + // 等 readLoop 真正起来(不再依赖"已启动"注入:那已被移除, + // 理由见 plugin.go 中"不注入终端已启动通知"的说明) + waitReadLoopReady(t, mterm) before := len(capture.snapshot()) // 主动关闭:模拟 close 工具路径(关 stopCh + 杀进程 + 释放 PTY)。 @@ -794,9 +828,9 @@ func TestProcessExit_StillNotifies(t *testing.T) { ts := newTestSession(term) startReadLoop(p, sdkInst, ts) - if !waitInjected(capture, "已启动", 2*time.Second) { - t.Fatal("expected startup notification") - } + // 等 readLoop 真正起来(不再依赖"已启动"注入:那已被移除, + // 理由见 plugin.go 中"不注入终端已启动通知"的说明) + waitReadLoopReady(t, term) // 进程自己退出(**不是** Close):mockTerm 返回 process exited。 term.mu.Lock() @@ -835,9 +869,7 @@ func TestClose_NoEcho_Repeated(t *testing.T) { term := newMockTerm() ts := newTestSession(term) startReadLoop(p, sdkInst, ts) - if !waitInjected(capture, "已启动", 2*time.Second) { - t.Fatal("expected startup notification") - } + waitReadLoopReady(t, term) before := len(capture.snapshot()) // ★ 关键:先在 PTY 缓冲里**留下未读数据**,再关闭。 @@ -891,16 +923,17 @@ func TestClose_RemovesSessionFromTable(t *testing.T) { }) sdkInst.SetIOInjector(capture) - ts := newTestSession(newMockTerm()) + mterm2 := newMockTerm() + ts := newTestSession(mterm2) ts.id = "term_leak" p.mu.Lock() p.sessions = map[string]*TerminalSession{"term_leak": ts} p.mu.Unlock() startReadLoop(p, sdkInst, ts) - if !waitInjected(capture, "已启动", 2*time.Second) { - t.Fatal("expected startup notification") - } + // 等 readLoop 真正起来(不再依赖"已启动"注入:那已被移除, + // 理由见 plugin.go 中"不注入终端已启动通知"的说明) + waitReadLoopReady(t, mterm2) ts.Close() // 内部会 <-t.done,等 readLoop 走完 @@ -952,3 +985,91 @@ func TestCleanup_DoesNotNotifyForClosedTerminal(t *testing.T) { } _ = capture } + +// ── 2026-10-25 新增:禁止「自造状态回声」的判据 ────────────────── +// +// 背景(真机事故):agent 通过 terminal_create 建终端后,插件注入 +// 「[终端 X 已启动]」;超时后又注入「[终端 X 已超时关闭]」。 +// 这两条都是**插件自己造成、且 agent 已能从工具返回值推导**的状态, +// 而每条注入都会唤醒一整轮 LLM 推理。实测后果:agent 反复向用户 +// 解释"终端怎么又没了",真正要做的活一件没做。 +// +// 下面两条是**反向判据**:断言这些注入**不再出现**。没有它们, +// 后人(或我自己)很容易为了"让 agent 感知"把注入加回来,而那时 +// 上面那些 waitReadLoopReady 的测试依然全绿 —— 缺陷会静默复发。 + +// 判据一:建终端不得产生「已启动」注入(terminal_create 返回值已足够)。 +func TestNoStartupNotificationInjected(t *testing.T) { + p := New("agentcli") + p.notifyBytes = 1 << 20 // 不触发缓冲通知,隔离变量 + + capture := &injectCapture{} + sdkInst := sdk.New("agentcli", sdk.SDKConfig{ + RegTool: newToolCapture().RegisterTool, + RegStage: func(sdk.Stage, sdk.StageHandler) {}, + RegAPI: func(string) error { return nil }, + Settings: sdk.NewSettings("agentcli", nil), + }) + sdkInst.SetIOInjector(capture) + + term := newMockTerm() + ts := newTestSession(term) + startReadLoop(p, sdkInst, ts) + waitReadLoopReady(t, term) + + // 给足时间让任何潜在注入发生 + time.Sleep(150 * time.Millisecond) + + for _, text := range capture.snapshot() { + if strings.Contains(text, "已启动") { + t.Fatalf("不得注入「已启动」(terminal_create 返回值已告知): %q", text) + } + } + + close(ts.stopCh) + <-ts.done +} + +// 判据二:超时关闭不得注入(创建时 timeout 已告知)。 +func TestNoExpiredNotificationInjected(t *testing.T) { + p := New("agentcli") + p.notifyBytes = 1 << 20 + + capture := &injectCapture{} + sdkInst := sdk.New("agentcli", sdk.SDKConfig{ + RegTool: newToolCapture().RegisterTool, + RegStage: func(sdk.Stage, sdk.StageHandler) {}, + RegAPI: func(string) error { return nil }, + Settings: sdk.NewSettings("agentcli", nil), + }) + sdkInst.SetIOInjector(capture) + + term := newMockTerm() + ts := newTestSession(term) + // 设为已超时:readLoop 下一轮循环就会走 IsExpired 分支 + ts.mu.Lock() + ts.timeout = time.Nanosecond + ts.createdAt = time.Now().Add(-time.Hour) + ts.mu.Unlock() + + startReadLoop(p, sdkInst, ts) + waitReadLoopReady(t, term) + + // 等 removeSession 真的发生(说明走了超时分支) + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + p.mu.Lock() + _, still := p.sessions[ts.id] + p.mu.Unlock() + if !still { + break + } + time.Sleep(2 * time.Millisecond) + } + + for _, text := range capture.snapshot() { + if strings.Contains(text, "已超时关闭") { + t.Fatalf("不得注入「已超时关闭」(创建时 timeout 已告知): %q", text) + } + } +} From c9a32a98f06a9249eeda6ec61bf358c33401f29a Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sun, 25 Oct 2026 20:16:16 +0800 Subject: [PATCH 3/5] =?UTF-8?q?feat(agent):=20=E8=B6=85=E9=95=BF=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E7=BB=93=E6=9E=9C=E8=90=BD=E7=9B=98=20+=20toolresult?= =?UTF-8?q?=5Fread=20=E6=8C=89=E8=8C=83=E5=9B=B4=E5=9B=9E=E8=AF=BB?= =?UTF-8?q?=EF=BC=88=E8=A1=A5=E9=BD=90=E5=B7=A5=E5=85=B7=E7=8E=AF=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题(WSL2 真机诊断报告问题 2):单条工具结果 36783 token,直接冲破 整个对话预算 → overflow 判「单条超限,裁剪救不了」→ 整轮终止。 模型看到的只是"对话没反应"。日志:max_ctx=32768 target=26214 fixed=498 mem=8572 ctx=17144,而单条 36783 是它的 2.1 倍。 此前两条路都不通,本提交走第三条: · **截断**(方案 A):模型拿到残缺信息且**不知道**被截过,会基于残缺 数据下结论 —— 与本仓反复吃亏的「静默降级」同族。 · **只统计不裁剪**(方案 B,toolresult_budget.go):设计上正确(不欺骗 模型),但停在「报告」一步 —— oversizeTools 计数**没有任何消费者** (OversizeToolReports/OversizeToolNames 全仓零调用者),所以那条超大 结果照样整条进上下文、照样撞窗口。 本提交把方案 B 缺的那一半补上: 结果超限(沿用 toolresult_budget.go 的 ContextTokens/8 阈值) → 正文完整写入 /toolresults//tool_N.txt → 上下文里换成「预览 + event_id 句柄 + 总字节/行数 + 读法」 → 模型调 toolresult_read(event_id, offset, limit) 取任意区间(≤64KB/次) → 事件离开 L1 时全文目录一并删除 设计要点: · **存活期跟随事件,不引入 TTL**。与 Blocks 同一套处置(见 mediaref.go 「块存储在事件自身,不维护引用账本」)。刻意不做独立过期/清理定时器 ——那会让"一件事记两处",两处一旦不一致就出现"事件还在但全文没了"。 · **正文不内联进 context.json**:它每 5s 全量重写,内联几 MB 正文会让 每次 flush 都写几 MB。 · **磁盘 IO 放 context.go**(记忆面持久化,架构守卫允许的唯一落点), toolresult_store.go 只留纯逻辑 —— 满足 TestKernelHasNoMediaFileReading。 · **toolresult_read 按需暴露**:只在真有落盘结果时注册 schema (本仓已有"175 个工具占满上下文"的教训)。 · 落盘失败**保持原内容**:宁可撞窗口也不让模型什么都看不到。 判据(toolresult_store_test.go,7 条,3 条经变异验证): · TestOffloadReplacesFullTextWithPreview —— 上下文拿预览非全文,且三要素 齐全(说明是预览/给句柄/给读法)。变异:返回全文 ⇒ 红。 · TestReadBackReassemblesByteIdentical —— 分段拼回**逐字节相同**,含中文 多字节字符。这是"信息不丢"的唯一硬证据。变异:少读 1 字节 ⇒ 红。 · TestAppendClaimsPendingToolResults —— 事件进 L1 必须认领落盘记录, 否则重启后句柄全失效。变异:不认领 ⇒ 红。 · TestReadAfterPurgeIsExplicit —— 淘汰后明确告知,不静默返回空串。 · TestPersistedToEventDirNotInline / TestReadWithBadHandleIsActionable / TestSmallResultNotOffloaded --- internal/agent/core/context.go | 184 +++++++++++- internal/agent/core/task.go | 27 +- internal/agent/core/toolcall.go | 6 + internal/agent/core/tooldefs.go | 19 ++ internal/agent/core/toolresult_store.go | 294 +++++++++++++++++++ internal/agent/core/toolresult_store_test.go | 233 +++++++++++++++ 6 files changed, 751 insertions(+), 12 deletions(-) create mode 100644 internal/agent/core/toolresult_store.go create mode 100644 internal/agent/core/toolresult_store_test.go diff --git a/internal/agent/core/context.go b/internal/agent/core/context.go index 8ab48792..750f4eaa 100644 --- a/internal/agent/core/context.go +++ b/internal/agent/core/context.go @@ -3,6 +3,7 @@ package core import ( "encoding/json" "fmt" + "io" "log" "os" "path/filepath" @@ -35,6 +36,17 @@ type ContextEvent struct { ToolsUsed []string `json:"tools_used,omitempty"` ToolResults []ToolResultItem `json:"tool_results,omitempty"` + // ToolResultStores 是本事件捕获的「超大工具结果」落盘记录。 + // + // ★ 与 Blocks 同一套处置:正文随事件目录持久化,**不维护引用账本**, + // 存活期由事件的存活期决定(事件离开 L1 ⇒ 正文目录一并删除)。 + // 见 toolresult_store.go 的说明。 + // + // 这里只存元信息(工具名、文件名、总字节/行数),正文在 + // /toolresults//tool_N.txt —— 因为 + // context.json 每 5s 全量重写一次,内联大正文会让每次 flush 都写几 MB。 + ToolResultStores []ToolResultStore `json:"tool_result_stores,omitempty"` + // NoMemory 标记这条事件**不参与记忆计算**(不向量化、不提关键词、不蒸馏), // 但**照常参与归档**(离开 L1 时会写回 L2)。 // @@ -60,15 +72,21 @@ type ContextEvent struct { const contextFlushInterval = 5 * time.Second type RelevanceContext struct { - mu sync.Mutex - events []*ContextEvent - embedder *memory.StaticEmbedder - denseSpace vector.MultimodalEmbedder - savePath string - saveTimer *time.Timer - dirty bool - toolDefLookup func(name string) *sdk.ToolDef - channelDefLookup func(name string) (sdk.ChannelDef, bool) + mu sync.Mutex + events []*ContextEvent + embedder *memory.StaticEmbedder + denseSpace vector.MultimodalEmbedder + savePath string + // pendingToolResults 存放"已落盘但事件还没进 L1"的超长工具结果记录。 + // + // 为什么需要:同一帧可能先落盘、后 append 事件(task.go 的顺序)。 + // 没有它,句柄会在事件进入前悬空,模型读到的就是"未找到"。 + // 事件 append 后由 claimPendingToolResults 认领并清空。 + pendingToolResults map[string][]ToolResultStore + saveTimer *time.Timer + dirty bool + toolDefLookup func(name string) *sdk.ToolDef + channelDefLookup func(name string) (sdk.ChannelDef, bool) // protectedCount 是裁剪时**无条件保留**的最近事件条数。 // @@ -400,15 +418,36 @@ func (c *RelevanceContext) Append(evt ContextEvent) { c.computeVector(&evt) c.events = append(c.events, &evt) + // 认领"先落盘、后进 L1"的超长工具结果记录(已在锁内,直接操作)。 + // + // 为什么挂在 Append 而不是各调用点:Append 是事件进入 L1 的唯一咽喉 + // (task.go 有三处调用),散点调用必然漏一条路径——那一条上的句柄 + // 就永久悬空(模型拿到 event_id 去读得到"未找到")。 + c.claimPendingToolResultsLocked(&evt) + c.save() } +// claimPendingToolResultsLocked 是 claimPendingToolResults 的持锁版本, +// 供 Append/InsertByTimestamp 这类已持锁的路径调用。 +func (c *RelevanceContext) claimPendingToolResultsLocked(evt *ContextEvent) { + if c == nil || evt == nil || evt.ID == "" || len(c.pendingToolResults) == 0 { + return + } + if sts, ok := c.pendingToolResults[evt.ID]; ok { + evt.ToolResultStores = append(evt.ToolResultStores, sts...) + delete(c.pendingToolResults, evt.ID) + c.dirty = true + } +} + func (c *RelevanceContext) InsertByTimestamp(evt ContextEvent) { c.mu.Lock() defer c.mu.Unlock() scrubEventSecrets(&evt) // 同 Append:咽喉处拦截 c.computeVector(&evt) + c.claimPendingToolResultsLocked(&evt) // 同 Append:认领待登记的落盘记录 idx := sort.Search(len(c.events), func(i int) bool { return c.events[i].Timestamp.After(evt.Timestamp) @@ -573,6 +612,17 @@ func (c *RelevanceContext) PruneWithProtected(currentInput string, topK int, doc s.event.Blocks = nil } } + // 超长工具结果全文同样**跟随事件**离开 L1,不单独过期。 + // + // 与 Blocks 一致:块迁移到 L2(块本身进 doc),而全文是纯现场 + // 物料、不进 doc,所以这里直接删目录。删除失败只在日志留痕 + // (readToolResultBody 会答复"已随事件淘汰"),不会静默成功。 + for _, s := range archive { + if s.event != nil && s.event.ID != "" && len(s.event.ToolResultStores) > 0 { + c.purgeToolResultDir(s.event.ID) + s.event.ToolResultStores = nil + } + } } } @@ -666,3 +716,119 @@ func convertToolResults(items []ToolResultItem) []document.ToolResultItem { } return result } + +// ── 超长工具结果全文的持久化(记忆面 IO 的合法落点)────────────────── +// +// 放这里而不是 toolresult_store.go:本包有架构守卫 +// TestKernelHasNoMediaFileReading,内核除「记忆面持久化」(本文件)外不得 +// 做本地文件读。工具结果全文落盘本质是"记住一份东西",属同一职责。 + +// persistToolResult 把正文写入事件目录,返回元信息。 +func (c *RelevanceContext) persistToolResult(evtID, tool, text string) (*ToolResultStore, error) { + if c == nil { + return nil, fmt.Errorf("toolresult: context 为空") + } + if evtID == "" { + return nil, fmt.Errorf("toolresult: evtID 为空(调用方须先分配事件 ID)") + } + base := filepath.Join(filepath.Dir(c.savePath), toolResultDirName, evtID) + if err := os.MkdirAll(base, 0755); err != nil { + return nil, fmt.Errorf("toolresult: 建目录 %s: %w", base, err) + } + + name := fmt.Sprintf("tool_%d.txt", nextToolResultSeq(base)) + + body := text + truncated := 0 + if len(body) > toolResultStoreHardCap { + truncated = len(body) - toolResultStoreHardCap + body = body[:toolResultStoreHardCap] + } + if err := os.WriteFile(filepath.Join(base, name), []byte(body), 0644); err != nil { + return nil, fmt.Errorf("toolresult: 写正文: %w", err) + } + + return &ToolResultStore{ + Tool: tool, + File: name, + TotalBytes: len(text), + TotalLines: strings.Count(body, "\n") + 1, + TruncatedBytes: truncated, + }, nil +} + +// nextToolResultSeq 数出事件目录里已有的 tool_*.txt,返回下一个序号。 +// +// 不用内存计数器:进程重启后计数器归零会覆盖旧文件。数目录是幂等的。 +func nextToolResultSeq(dir string) int { + entries, err := os.ReadDir(dir) + if err != nil { + return 0 + } + n := 0 + for _, e := range entries { + var idx int + if _, err := fmt.Sscanf(e.Name(), "tool_%d.txt", &idx); err == nil && idx >= n { + n = idx + 1 + } + } + return n +} + +// readToolResultBody 按字节区间读取正文。 +// +// offset 从 0 开始;limit<=0 表示读到结尾。返回内容与「是否已到末尾」。 +func (c *RelevanceContext) readToolResultBody(evtID string, st *ToolResultStore, offset, limit int) (string, bool, error) { + if c == nil || st == nil { + return "", true, fmt.Errorf("toolresult: 无记录") + } + if offset < 0 { + offset = 0 + } + p := filepath.Join(filepath.Dir(c.savePath), toolResultDirName, evtID, st.File) + f, err := os.Open(p) + if err != nil { + // 事件已被淘汰时走到这里。给**明确**答复而不是静默空串: + // 模型应当知道"这份结果已不可读",而不是以为结果是空的。 + if os.IsNotExist(err) { + return "", true, fmt.Errorf("toolresult: 该结果已随事件淘汰(%s)", st.File) + } + return "", true, fmt.Errorf("toolresult: 打开正文: %w", err) + } + defer f.Close() + + if offset > 0 { + if _, err := f.Seek(int64(offset), 0); err != nil { + return "", true, fmt.Errorf("toolresult: 定位 %d: %w", offset, err) + } + } + if limit <= 0 { + limit = st.TotalBytes - offset + } + if limit < 0 { + limit = 0 + } + buf := make([]byte, limit) + n, rerr := io.ReadFull(f, buf) + if rerr != nil && rerr != io.EOF && rerr != io.ErrUnexpectedEOF { + return "", true, fmt.Errorf("toolresult: 读取: %w", rerr) + } + out := string(buf[:n]) + atEnd := offset+n >= st.TotalBytes + return out, atEnd, nil +} + +// purgeToolResultDir 删除某事件的全文目录。 +// +// 由 Prune 在事件离开 L1 时调用:全文跟随事件生命周期,不单独过期。 +func (c *RelevanceContext) purgeToolResultDir(evtID string) { + if c == nil || c.savePath == "" || evtID == "" { + return + } + p := filepath.Join(filepath.Dir(c.savePath), toolResultDirName, evtID) + if err := os.RemoveAll(p); err != nil { + // 删不掉不影响正确性(readToolResultBody 会给"已淘汰"),但要留痕, + // 否则残留会无声地占满磁盘。 + log.Printf("[agent] purge toolresult dir %s: %v", p, err) + } +} diff --git a/internal/agent/core/task.go b/internal/agent/core/task.go index 54203f93..3e04109d 100644 --- a/internal/agent/core/task.go +++ b/internal/agent/core/task.go @@ -117,6 +117,14 @@ type TaskFrame struct { // 在若干帧后突然失去超页处理能力(而那时恰恰最需要它)。 OverflowRecover int + // EventID 是本帧工具结果落盘时使用的**事件标识**。 + // + // 为什么要挂在帧上:同一帧内可能有多条超长结果,它们都属于同一个 + // 上下文事件,应当落在同一个事件目录里(tool_0.txt / tool_1.txt)。 + // 惰性生成(首次落盘时分配),因为绝大多数帧没有任何超长结果—— + // 全量生成会给每条事件多加一个字段,与 ContextEvent.ID 的取舍一致。 + EventID string + // 当前工具批 PendingTools []agentAPI.ToolCall ToolIdx int @@ -1173,14 +1181,27 @@ func (a *Agent) stepToolAfter(f *TaskFrame) stepOutcome { } f.Msgs = append(f.Msgs, toolMsg) - // 工具结果大小统计(方案 B:**只统计不裁剪**)。 + // 工具结果大小处置:超限则**全文落盘 + 上下文只放预览**。 // // 为什么在这里而不是 stepToolExec:那里拿到的 outcome.Text 尚未经 // after_toolcall 阶段改写,而模型最终看到的是**这里**的内容。 - // ⚠️ 刻意不截断 —— 截断会让模型基于残缺数据下结论,且它不知道被截过 - // (与本仓「静默降级」同族)。处置权交回调度器/上层。 + // + // ★ 刻意**不截断**(截断会让模型拿到残缺信息且不知道被截过,与本仓 + // 「静默降级」同族)。也刻意**不原样放行**(那正是 36783 token 单条 + // 结果冲破预算、整轮终止的成因)。 + // + // 走的是第三条路:正文完整保存到事件目录,上下文里换成 + // 「预览 + 句柄 + 总量」+ toolresult_read 工具按需取区间。 + // 取哪一段由模型决定,信息不丢(判据要求分段拼回逐字节相同)。 if a.checkToolResultSize(tc.Name, toolMsg.Content) { f.noteOversizeTool(tc.Name) + if replaced, ok := a.offloadToolResult(f, tc.Name, toolMsg.Content); ok { + toolMsg.Content = replaced + result = replaced + if len(f.ToolResults) > 0 { + f.ToolResults[len(f.ToolResults)-1].Output = replaced + } + } } if mediaMsg != nil { // 延迟到批末(见 batchAttachments 的注释):当场追加会把后续 tool 消息 diff --git a/internal/agent/core/toolcall.go b/internal/agent/core/toolcall.go index 281dd82f..88651e33 100644 --- a/internal/agent/core/toolcall.go +++ b/internal/agent/core/toolcall.go @@ -389,6 +389,12 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall, turnScenes []string) str return "" } switch tc.Name { + case "toolresult_read": + txt, err := a.readStoredToolResult(tc.Arguments) + if err != nil { + return "toolresult_read 失败:" + err.Error() + } + return txt case "memory_recall": query, _ := tc.Arguments["query_intent"].(string) depth, _ := tc.Arguments["depth"].(float64) diff --git a/internal/agent/core/tooldefs.go b/internal/agent/core/tooldefs.go index cbdc63d1..5a419868 100644 --- a/internal/agent/core/tooldefs.go +++ b/internal/agent/core/tooldefs.go @@ -577,6 +577,25 @@ func (a *Agent) buildToolDefs() []interface{} { } } + // 超长工具结果的按范围回读工具。 + // + // ★ 只在**真的存在**落盘结果时才暴露: + // · 绝大多数会话没有超长结果,无条件暴露等于每轮多付一份 schema 开销 + // (本仓已有"175 个工具占满上下文"的实测教训); + // · 没有句柄时这个工具对模型毫无用处,给了只会诱导它乱猜 event_id。 + if a.hasStoredToolResults() { + tools = append(tools, toolDef("toolresult_read", + "【工具结果回读】读取某个超大工具结果的指定字节区间。"+ + "当工具返回内容过大时,上下文里给你的只是预览,会附一个 event_id 句柄和总字节数。"+ + "用本工具按 offset/limit 取其余部分;单次最多 64KB。"+ + "结果不会自动过期,但会**随那条上下文事件淘汰**——事件被裁掉后再读会明确告知已淘汰。", + map[string]interface{}{ + "event_id": map[string]interface{}{"type": "string", "description": "预览里给出的句柄(形如 evt_1770000000000000000)"}, + "offset": map[string]interface{}{"type": "number", "description": "起始字节偏移,从 0 开始;省略即从头"}, + "limit": map[string]interface{}{"type": "number", "description": "读取字节数,省略或 <=0 取 64KB;不该超过剩余量"}, + }, "event_id")) + } + if a.personaStore != nil { tools = append(tools, toolDef("persona_set", "【首启人格】落地用户的人格选择并记录「已经问过」。仅在用户明确答复后调用:默认用 mode=default;自定义用 mode=custom 并把人格内容放进 content;用户说以后再说用 mode=later。", map[string]interface{}{ "mode": map[string]interface{}{"type": "string", "description": "default | custom | later"}, diff --git a/internal/agent/core/toolresult_store.go b/internal/agent/core/toolresult_store.go new file mode 100644 index 00000000..52eaa954 --- /dev/null +++ b/internal/agent/core/toolresult_store.go @@ -0,0 +1,294 @@ +package core + +// 超长工具结果的「全文落盘 + 按范围回读」(纯逻辑部分)。 +// +// ## 为什么需要它 +// +// 工具结果进上下文时没有任何长度上限(task.go 直接 `Content: result`)。 +// 实测生产一次 36783 token 的单条结果直接冲破整个对话预算,overflow 判定 +// 「单条超限,裁剪救不了」→ 整轮终止。模型看到的是「对话没反应」。 +// +// 之前有两条错的路子,这里都不走: +// +// - **截断**(方案 A):模型拿到残缺信息且**不知道**被截过,会基于残缺 +// 数据下结论。与本仓反复吃亏的「静默降级」同族。 +// - **只统计不裁剪**(方案 B,toolresult_budget.go):设计上是对的(不欺骗 +// 模型),但停在「报告」这一步 —— oversizeTools 计数没有任何消费者, +// 所以那条超大结果照样整条进上下文,照样撞窗口。 +// +// 本文件补的是方案 B 缺的那一半:结果**全文保存**,上下文里只放 +// 「预览 + 句柄 + 总量导航」,模型用 toolresult_read 按 offset/limit +// 取任意区间。 +// +// - 模型不会被骗:它明确知道看到的是预览,并有手段取全文 +// - 取哪一段由**模型**决定,不是内核武断裁一刀 +// - 上下文预算可控,且**信息不丢**(判据要求分段拼回逐字节相同) +// +// ## 存活期:跟随事件,不引入 TTL +// +// 全文存在**事件自己的目录**里(与 Blocks 同一套处置,见 mediaref.go 的 +// 「块存储在事件自身,不维护引用账本」)。所以: +// +// 事件在 L1 ⇒ 全文可读 +// 事件被淘汰 ⇒ 全文随事件目录一并删除 +// +// 刻意**不做**独立的过期时间/清理定时器:那会让「一件事记两处」, +// 且两处一旦不一致,就会出现「事件还在但全文没了」或反过来的静默错误。 +// +// ★ 磁盘读写不在这里,而在 context.go(persistToolResult / readToolResultBody / +// purgeToolResultDir)。原因:本包有架构守卫 TestKernelHasNoMediaFileReading, +// 内核除「记忆面持久化」(context.go)外不得做本地文件读。这本来就是一件 +// 「记住一份东西」的事,归到持久化文件既满足守卫也符合职责。 + +import ( + "fmt" + "log" + "strings" +) + +// 工具结果全文的存储目录名(与 context.json 同级)。 +const toolResultDirName = "toolresults" + +// ToolResultStore 是一条「超大工具结果」的落盘记录。 +// +// 它**只存元信息**进 context.json;正文在 +// +// /toolresults//tool_.txt +// +// 这样 context.json 不会因为几条大结果膨胀成几 MB(它每 5s 全量重写一次)。 +type ToolResultStore struct { + // Tool 是产出这条结果的工具名(模型据此判断"是哪个工具给的")。 + Tool string `json:"tool"` + // File 是正文相对事件目录的文件名(如 tool_0.txt),不含绝对路径—— + // 数据目录整体搬迁后仍然有效。 + File string `json:"file"` + // TotalBytes / TotalLines 供模型规划怎么读(先读尾部?还是按行定位?)。 + TotalBytes int `json:"total_bytes"` + TotalLines int `json:"total_lines"` + // TruncatedBytes 是**没有**落盘的字节数。非 0 表示落盘时触到了硬上限, + // 必须让模型知道(否则它以为全文就这么长)。默认 0。 + TruncatedBytes int `json:"truncated_bytes,omitempty"` +} + +// toolResultStoreHardCap 是单条结果落盘的硬上限(1 GiB)。 +// +// 为什么还要上限:不设的话一个 `cat /dev/urandom` 就能把磁盘写满。 +// 但它**不是**静默截断 —— 超限部分记进 TruncatedBytes 并明确告诉模型。 +const toolResultStoreHardCap = 1 << 30 + +// storeToolResult 把超长结果正文落盘,返回元信息。IO 委托给 context.go。 +func (a *Agent) storeToolResult(evtID, tool, text string) (*ToolResultStore, error) { + if a == nil || a.context == nil { + return nil, fmt.Errorf("toolresult: agent 未就绪") + } + if a.context.savePath == "" { + return nil, fmt.Errorf("toolresult: 未配置持久化目录,无法落盘") + } + return a.context.persistToolResult(evtID, tool, text) +} + +// previewToolResult 生成进上下文的预览:头部若干行 + 句柄 + 读法。 +// +// ★ 文案三要素(缺一不可,判据钉住): +// 1. 明确说明「这不是全文」以及总大小 —— 否则模型会当成完整内容下结论 +// 2. 给出句柄(事件 ID)与工具名 —— 否则它没法取 +// 3. 给出可用区间导航 —— 否则它不知道能读到哪 +func previewToolResult(evtID string, st *ToolResultStore, head string, headLines int) string { + var b strings.Builder + fmt.Fprintf(&b, "[工具 %s 返回内容过大,已完整保存,此处仅为预览]\n", st.Tool) + fmt.Fprintf(&b, "全文:%d 字节 / %d 行;句柄 event_id=%s\n", st.TotalBytes, st.TotalLines, evtID) + if st.TruncatedBytes > 0 { + fmt.Fprintf(&b, "⚠️ 落盘时超出上限,有 %d 字节**未保存**(不是全文)\n", st.TruncatedBytes) + } + fmt.Fprintf(&b, "\n--- 预览(前 %d 行)---\n%s\n--- 预览结束 ---\n", headLines, head) + fmt.Fprintf(&b, "\n如需其它部分,调用 toolresult_read(event_id=%q, offset=<字节偏移>, limit=<字节数>)。\n", evtID) + return b.String() +} + +// attachToolResultStore 把落盘记录挂到事件上(与 bindEventMedia 同构)。 +func attachToolResultStore(evt *ContextEvent, st *ToolResultStore) { + if evt == nil || st == nil { + return + } + if evt.ID == "" { + evt.ID = newEventID() + } + evt.ToolResultStores = append(evt.ToolResultStores, *st) +} + +// headLinesOf 截出前 n 行(按 rune 安全,不切断多字节字符)。 +func headLinesOf(s string, n int) string { + if n <= 0 { + n = previewHeadLines + } + lines := 0 + for i := 0; i < len(s); i++ { + if s[i] == '\n' { + lines++ + if lines >= n { + return s[:i+1] + } + } + } + return s +} + +// previewHeadLines 是预览默认保留的行数。 +const previewHeadLines = 40 + +// offloadToolResult 把超长结果落盘,返回替换后的预览文本。 +// +// 返回 ok=false 时调用方保持原内容不变(落盘失败不能吞掉结果—— +// 宁可撞窗口也不能让模型看不到任何东西,那是更严重的静默降级)。 +func (a *Agent) offloadToolResult(f *TaskFrame, tool, content string) (string, bool) { + if a == nil || a.context == nil || content == "" { + return "", false + } + evtID := "" + if f != nil { + evtID = f.EventID + } + if evtID == "" { + evtID = newEventID() + if f != nil { + f.EventID = evtID + } + } + st, err := a.storeToolResult(evtID, tool, content) + if err != nil { + // 落盘失败就保持原样:模型至少还能看到完整内容(哪怕撞窗口), + // 这比"什么都没看到"好。日志留痕供运维发现磁盘/权限问题。 + log.Printf("[agent] toolresult offload failed (tool=%s): %v — 保持原内容", tool, err) + return "", false + } + // ★ 登记到上下文里,否则 toolresult_read 找不到句柄。 + // + // storeToolResult 只负责写文件;"这条结果属于哪个事件、能被回读" + // 是上下文侧的记录。两者必须一起成立——只有文件没有登记,模型拿到 + // 句柄去读会得到"未找到",那正是本判据抓到的缺口。 + a.context.registerToolResultStore(evtID, st) + return previewToolResult(evtID, st, headLinesOf(content, previewHeadLines), previewHeadLines), true +} + +// readStoredToolResult 实现 toolresult_read:按事件句柄 + 字节区间读回全文。 +// +// 这是「工具环」设计里缺的那一半——超长结果不再整条进上下文,而是存起来 +// 由模型自己决定读哪一段。取哪一段是**模型**的判断,不是内核的武断裁剪。 +func (a *Agent) readStoredToolResult(args map[string]interface{}) (string, error) { + evtID, _ := args["event_id"].(string) + if evtID == "" { + return "", fmt.Errorf("缺少 event_id(预览里给出的句柄)") + } + offset := 0 + if v, ok := args["offset"].(float64); ok { + offset = int(v) + } + limit := 0 + if v, ok := args["limit"].(float64); ok { + limit = int(v) + } + if offset < 0 { + return "", fmt.Errorf("offset 不能为负") + } + // 单次读取上限:防止模型一次要 500MB 把上下文再撞一次。 + // 64KB ≈ 16K token,留出余量给其它内容。 + const maxReadBytes = 64 * 1024 + if limit <= 0 { + limit = maxReadBytes + } + if limit > maxReadBytes { + limit = maxReadBytes + } + + if a.context == nil { + return "", fmt.Errorf("上下文不可用") + } + st := a.context.findToolResultStore(evtID) + if st == nil { + // 三种可能:句柄打错、事件已被淘汰、或这条结果当时落盘失败。 + // 都给出**明确**答复,不静默返回空串(空串会被误当成"内容就是空")。 + return "", fmt.Errorf("未找到 event_id=%s 的工具结果记录:可能句柄有误,或该事件已随上下文淘汰(全文跟随事件生命周期,不单独保留)", evtID) + } + body, atEnd, err := a.context.readToolResultBody(evtID, st, offset, limit) + if err != nil { + return "", err + } + header := fmt.Sprintf("[%s 的第 %d-%d 字节,共 %d 字节%s]", + st.Tool, offset, offset+len(body), st.TotalBytes, map[bool]string{true: ",已到末尾", false: ""}[atEnd]) + return header + "\n" + body, nil +} + +// findToolResultStore 按事件 ID 找到落盘记录(在 L1 事件里查)。 +func (c *RelevanceContext) findToolResultStore(evtID string) *ToolResultStore { + if c == nil { + return nil + } + c.mu.Lock() + defer c.mu.Unlock() + for _, evt := range c.events { + if evt == nil || evt.ID != evtID { + continue + } + if len(evt.ToolResultStores) == 0 { + return nil + } + return &evt.ToolResultStores[0] + } + // 事件还没进 L1(同一帧内刚落盘、尚未 append):查待登记表。 + // + // ★ 不能只看 c.events:落盘发生在 toolMsg 定稿时,而事件 append 在更晚。 + // 少了这一支,模型拿到句柄立刻去读会得到"未找到"——判据 + // TestReadBackReassemblesByteIdentical 抓到的正是这个。 + if sts, ok := c.pendingToolResults[evtID]; ok && len(sts) > 0 { + return &sts[0] + } + return nil +} + +// hasStoredToolResults 判断当前上下文里是否有可回读的超长工具结果。 +// +// 供 buildToolDefs 决定要不要暴露 toolresult_read(见那里的说明)。 +func (a *Agent) hasStoredToolResults() bool { + if a == nil || a.context == nil { + return false + } + return a.context.hasAnyToolResultStore() +} + +// hasAnyToolResultStore 扫一遍 L1 事件,看有没有落盘记录。 +func (c *RelevanceContext) hasAnyToolResultStore() bool { + if c == nil { + return false + } + c.mu.Lock() + defer c.mu.Unlock() + for _, evt := range c.events { + if evt != nil && len(evt.ToolResultStores) > 0 { + return true + } + } + return false +} + +// registerToolResultStore 把落盘记录登记到对应事件上。 +// +// 若该事件还没进 L1(同一帧内多条超长结果时可能先于事件 append), +// 先挂在 context 的待登记表里,等事件进来后再认领——否则句柄会悬空。 +func (c *RelevanceContext) registerToolResultStore(evtID string, st *ToolResultStore) { + if c == nil || st == nil || evtID == "" { + return + } + c.mu.Lock() + defer c.mu.Unlock() + for _, evt := range c.events { + if evt != nil && evt.ID == evtID { + evt.ToolResultStores = append(evt.ToolResultStores, *st) + c.dirty = true + return + } + } + if c.pendingToolResults == nil { + c.pendingToolResults = map[string][]ToolResultStore{} + } + c.pendingToolResults[evtID] = append(c.pendingToolResults[evtID], *st) +} diff --git a/internal/agent/core/toolresult_store_test.go b/internal/agent/core/toolresult_store_test.go new file mode 100644 index 00000000..15a14e85 --- /dev/null +++ b/internal/agent/core/toolresult_store_test.go @@ -0,0 +1,233 @@ +package core + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// newStoreTestAgent 造一个带持久化目录的 agent(用临时目录,不碰生产数据)。 +func newStoreTestAgent(t *testing.T) (*Agent, string) { + t.Helper() + dir := t.TempDir() + savePath := filepath.Join(dir, "context.json") + a := &Agent{context: NewRelevanceContext(savePath, nil)} + return a, savePath +} + +// ── 判据 1:超大结果落盘,上下文只拿预览(不含全文)────────────── +// +// 这是本特性存在的理由:36783 token 的单条结果曾经整条进上下文, +// 直接冲破整个对话预算、整轮终止。 +func TestOffloadReplacesFullTextWithPreview(t *testing.T) { + a, _ := newStoreTestAgent(t) + f := &TaskFrame{} + + // 造一个明显超长的结果(500 行) + var sb strings.Builder + for i := 1; i <= 500; i++ { + sb.WriteString("line-") + sb.WriteString(strings.Repeat("x", 100)) + sb.WriteString("\n") + } + full := sb.String() + + prev, ok := a.offloadToolResult(f, "cmd_run", full) + if !ok { + t.Fatal("超长结果应当落盘") + } + + // ① 上下文里拿到的是预览,不是全文 + if len(prev) >= len(full) { + t.Fatalf("预览不应不小于全文(预览 %d 字节,全文 %d 字节)", len(prev), len(full)) + } + + // ② 三要素必须齐(缺任一条模型就成了被骗的一方) + if !strings.Contains(prev, "已完整保存") { + t.Error("预览必须说明这是预览、全文已保存") + } + if !strings.Contains(prev, "evt_") { + t.Error("预览必须给出 event_id 句柄") + } + if !strings.Contains(prev, "toolresult_read") { + t.Error("预览必须告知怎么读其余部分") + } + if !strings.Contains(prev, "字节") { + t.Error("预览必须给出总大小") + } + + // ③ 记录挂在事件上(供 findToolResultStore 找到) + if len(a.context.events) == 0 { + // offloadToolResult 只是落盘并返回预览,不负责 append 事件; + // 事件由调用方(task.go)创建。这里直接验证帧上的 EventID 已分配。 + } + if f.EventID == "" { + t.Fatal("落盘后帧上必须有 EventID,否则没法回读") + } +} + +// ── 判据 2:分段读回拼接后与原结果**逐字节相同** ──────────────── +// +// 这是「信息不丢」的唯一硬证据。没有它,本特性只是另一种形式的截断。 +func TestReadBackReassemblesByteIdentical(t *testing.T) { + a, _ := newStoreTestAgent(t) + f := &TaskFrame{} + + // 刻意用多字节字符(中文)+ 换行,确保按字节切不会切坏 UTF-8 + var sb strings.Builder + for i := 0; i < 300; i++ { + sb.WriteString("第") + sb.WriteString(strings.Repeat("测", 40)) + sb.WriteString("行\n") + } + full := sb.String() + + if _, ok := a.offloadToolResult(f, "files_read", full); !ok { + t.Fatal("落盘失败") + } + + // 按 4KB 分段读回,拼起来必须逐字节等于原文 + var got strings.Builder + offset := 0 + chunk := 4096 + for { + txt, err := a.readStoredToolResult(map[string]interface{}{ + "event_id": f.EventID, + "offset": float64(offset), + "limit": float64(chunk), + }) + if err != nil { + t.Fatalf("读 offset=%d 失败: %v", offset, err) + } + // 去掉头部那行元信息(形如 "[tool 的第 a-b 字节,共 n 字节]") + if idx := strings.Index(txt, "\n"); idx >= 0 { + got.WriteString(txt[idx+1:]) + } + if strings.Contains(txt, "已到末尾") { + break + } + offset += chunk + if offset > len(full)+chunk { + t.Fatal("读回死循环:offset 已超过总长仍未到末尾") + } + } + + if got.String() != full { + t.Fatalf("分段拼接与原结果不一致:读回 %d 字节,原文 %d 字节", got.Len(), len(full)) + } +} + +// ── 判据 3:事件淘汰后回读给**明确**答复,不是静默空串 ────────── +// +// 静默返回空串会让模型以为"内容就是空的",那是比失败更糟的误导。 +func TestReadAfterPurgeIsExplicit(t *testing.T) { + a, _ := newStoreTestAgent(t) + f := &TaskFrame{} + + if _, ok := a.offloadToolResult(f, "cmd_run", strings.Repeat("z", 200000)); !ok { + t.Fatal("落盘失败") + } + evtID := f.EventID + + // 模拟事件淘汰:删除其全文目录 + a.context.purgeToolResultDir(evtID) + + _, err := a.readStoredToolResult(map[string]interface{}{"event_id": evtID}) + if err == nil { + t.Fatal("事件淘汰后回读必须报错(明确告知已淘汰),不能返回空内容") + } + if !strings.Contains(err.Error(), "淘汰") { + t.Errorf("错误文案必须说明是「随事件淘汰」,实际: %v", err) + } +} + +// ── 判据 4:小结果不落盘(不改变既有行为)────────────────────── +func TestSmallResultNotOffloaded(t *testing.T) { + a, _ := newStoreTestAgent(t) + // 直接调 storeToolResult 是小结果场景的旁证: + // 触发权在 checkToolResultSize,这里验证"没触发就不产生文件"。 + if a.hasStoredToolResults() { + t.Fatal("初始状态不应有任何落盘记录") + } +} + +// ── 判据 5:handle 打错给出可执行提示 ────────────────────────── +func TestReadWithBadHandleIsActionable(t *testing.T) { + a, _ := newStoreTestAgent(t) + _, err := a.readStoredToolResult(map[string]interface{}{"event_id": "evt_不存在"}) + if err == nil { + t.Fatal("打错的句柄必须报错") + } + if !strings.Contains(err.Error(), "淘汰") && !strings.Contains(err.Error(), "句柄") { + t.Errorf("应说明是句柄有误或已淘汰,实际: %v", err) + } +} + +// ── 判据 6:落盘文件确实在事件目录下(不是内联进 context.json)──── +// +// 这条钉住"为什么不用内联":context.json 每 5s 全量重写,内联几 MB +// 正文会让每次 flush 都写几 MB。 +func TestPersistedToEventDirNotInline(t *testing.T) { + a, savePath := newStoreTestAgent(t) + f := &TaskFrame{} + if _, ok := a.offloadToolResult(f, "cmd_run", strings.Repeat("y", 200000)); !ok { + t.Fatal("落盘失败") + } + + p := filepath.Join(filepath.Dir(savePath), toolResultDirName, f.EventID, "tool_0.txt") + st, err := os.Stat(p) + if err != nil { + t.Fatalf("正文应在事件目录 %s: %v", p, err) + } + if st.Size() != 200000 { + t.Errorf("正文大小应为 200000,实际 %d", st.Size()) + } +} + +// ── 判据 7:事件进 L1 后必须认领自己的落盘记录 ────────────────── +// +// 缺陷现场(自测抓到):registerToolResultStore 把记录放进 pendingToolResults, +// 但认领函数没有任何调用点 ⇒ 事件进入 L1 后记录仍躺在待定表里。 +// 后果:进程重启后从 context.json 读回事件,ToolResultStores 是空的, +// 句柄全部失效(模型回读得到"未找到")。 +// +// ★ 这条必须走真实的 Append 路径,不能直接调认领函数—— +// +// 直接调就盖住了"没人调它"这个缺陷(变异验证:删掉 Append 里那行, +// 本判据必须变红)。 +func TestAppendClaimsPendingToolResults(t *testing.T) { + a, _ := newStoreTestAgent(t) + f := &TaskFrame{} + if _, ok := a.offloadToolResult(f, "cmd_run", strings.Repeat("w", 200000)); !ok { + t.Fatal("落盘失败") + } + evtID := f.EventID + + // 落盘时事件还没进 L1,记录应在待定表里 + if len(a.context.pendingToolResults[evtID]) == 0 { + t.Fatal("落盘后记录应先进入待定表") + } + + // 事件进入 L1(走真实 Append) + a.context.Append(ContextEvent{ID: evtID, Source: "agent", Input: "x"}) + + // ① 待定表应被清空 + if len(a.context.pendingToolResults[evtID]) != 0 { + t.Error("Append 后待定表应已认领清空") + } + // ② 事件上应挂上记录 + var found bool + for _, evt := range a.context.events { + if evt != nil && evt.ID == evtID && len(evt.ToolResultStores) > 0 { + found = true + } + } + if !found { + t.Fatal("Append 后事件上必须有 ToolResultStores,否则重启后句柄失效") + } + // ③ 依然可回读 + if _, err := a.readStoredToolResult(map[string]interface{}{"event_id": evtID}); err != nil { + t.Fatalf("认领后应仍可回读: %v", err) + } +} From a0813b55781ee58ef7794215db76b61fe00c0bbc Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sun, 25 Oct 2026 20:30:39 +0800 Subject: [PATCH 4/5] =?UTF-8?q?feat(ota):=20=E5=86=85=E6=A0=B8=E5=8D=87?= =?UTF-8?q?=E7=BA=A7=E6=8E=A2=E6=B5=8B=20+=20=E4=B8=8D=E5=85=BC=E5=AE=B9?= =?UTF-8?q?=E6=8F=92=E4=BB=B6=E8=87=AA=E5=8A=A8=E7=A6=81=E7=94=A8=EF=BC=88?= =?UTF-8?q?OTA=20=E5=8F=AA=E9=9D=A2=E5=90=91=E5=86=85=E6=A0=B8=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 范围 OTA 只处理 homed / depgraph.bin / waiter 三个文件,**不升级插件**。 插件与内核的兼容性改由「自动禁用 + 写明原因 + 界面展示」处理。 ## 1. homed --version(OTA 的前提) 新增该 flag,输出单行 key=value: version=1.4.0dev commit=c9a32a9 build_time=... sdk=1.3.0 ★ 为什么必须有:升级前要能**问一个二进制它是什么版本**。此前只能靠 WebUI API(需鉴权、需服务在跑),没法在临时目录里预检新包——那就只能 "先替换再看日志",而那时旧版本已经被覆盖了。 ## 2. cmd/homed-upgrade:默认只探测 homed-upgrade -pkg xxx.tar.gz # 探测 homed-upgrade -pkg xxx.tar.gz -apply # 执行 探测项:当前版本 / 包内版本 / 版本比较结论 / depgraph 是否同批 / **插件兼容性预判**(换内核后会有几个插件被自动禁用)。 版本比较区分四种情况,都给出可读理由(用户才敢按 -apply): · 可升级 / **降级**(明确警告)/ 完全相同(无需升级)/ **同版本不同 commit**(同版本重发——最容易漏掉的一种) `-apply` 走:预检 → 备份(只备本次会动的文件,不整目录拷)→ 原子替换 (写 .tmp 再 rename)→ 重启 → 版本复核 → 失败自动回滚。 ## 3. 兼容性兜底:第一次升级必然撞到的坑 实测踩到:线上内核是加 --version **之前**的版本,第一次探测必然读不到。 此时不能失败退出(否则所有用户第一次升级都要人工介入)。兜底链: ① 问 `homed --version` ② 扫二进制 rodata(不够可靠,仅尽力) ③ **问 waiter** —— 它与 homed 同批构建(同版本同 commit)且一直支持 --version。实测这条成功识别出当前是 1.4.0/98850e1。 ④ 都失败 → 版本比较降级为"未知",**不阻断**其余预检。 ## 4. 不兼容插件自动禁用(替代"升级插件") 内核换版本后,用旧 SDK 编的插件握手会判主版本不匹配。此前只是 `log.Printf` 后返回 false:每次重启重试一遍失败一遍、日志反复刷, 用户在界面上只看到"插件不见了"。 改为:持久禁用 + 写明原因(`maybeAutoDisableIncompatible`)。 `disabled_plugins` 表新增 reason 列(带 ALTER 迁移,旧库补列), `sdk.DisabledPluginInfo` 同步加 Reason,随既有 settings API 到前端。 ★ **只认"主版本不匹配"这一类**:端口被占、配置缺失、插件崩溃都是 可能自愈的临时/环境问题。把它们也自动禁用,等于把可修复的故障变成 "永不加载",比反复重试糟糕得多。 判据: · cmd/homed-upgrade/main_test.go — 5 种版本比较结论 + 未知不阻断 + semver 解析(1.4.0dev 不得被当 1.4.0 比大小)+ waiter 输出解析 (日期不得被当版本号) · internal/plugin/autodisable_test.go — 认得出主版本不匹配(且 reason 必须带两端版本号)/ 四类临时故障**不得**被误判为版本不兼容 --- cmd/homed-upgrade/main.go | 640 ++++++++++++++++++++++++++++ cmd/homed-upgrade/main_test.go | 106 +++++ cmd/homed/bootstrap.go | 15 + internal/config/registry.go | 28 +- internal/plugin/autodisable_test.go | 61 +++ internal/plugin/registry.go | 58 +++ internal/sdk/plugin.go | 5 + 7 files changed, 909 insertions(+), 4 deletions(-) create mode 100644 cmd/homed-upgrade/main.go create mode 100644 cmd/homed-upgrade/main_test.go create mode 100644 internal/plugin/autodisable_test.go diff --git a/cmd/homed-upgrade/main.go b/cmd/homed-upgrade/main.go new file mode 100644 index 00000000..b1dfcbf6 --- /dev/null +++ b/cmd/homed-upgrade/main.go @@ -0,0 +1,640 @@ +// Command homed-upgrade 是**内核**的本地升级闭环(默认只探测,不写盘)。 +// +// ## 范围:只面向内核 +// +// 只处理 homed / depgraph.bin / waiter 三个文件。**不碰插件**——插件有独立的 +// 升级通道(pluginmgr),且插件与内核的兼容性由"自动禁用不兼容插件"处理 +// (见 internal/plugin/registry.go 的 maybeAutoDisableIncompatible): +// 换内核后旧 SDK 编的插件会握手失败,内核把它持久禁用并写明原因, +// 用户在界面上直接看到"为什么没了",而不是翻日志。 +// +// ## 为什么默认只探测 +// +// 替换二进制不可逆,且内核是这个机器上唯一常驻的关键服务。默认只做 +// 探测与预检、把"会发生什么"完整报出来;`-apply` 才真动手。 +// 沿同仓 homed-kb-migrate / homed-predicate-migrate 的惯例。 +// +// ## 探测什么(-probe) +// +// 1. 当前运行的内核版本(从 /proc 找进程,读它自己的 --version 输出) +// 2. 新包里的内核版本 +// 3. 两者比较:能否升级、是不是降级、commit 是否真的变了 +// 4. depgraph.bin 与新二进制是否匹配(包内自带,必须同批替换) +// 5. 现有插件与**新**内核的兼容性预判(SDK 主版本) +// +// 第 5 条是 OTA 里最容易出事的地方:内核换了、插件没换。这里提前算出来, +// 好让用户在动手前就知道"升完会有 N 个插件被自动禁用"。 +package main + +import ( + "archive/tar" + "compress/gzip" + "encoding/json" + "flag" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "sort" + "strconv" + "strings" + "time" +) + +func main() { + pkgPath := flag.String("pkg", "", "新安装包(.tar.gz);给出即做探测") + binPath := flag.String("bin", "/usr/local/bin/homed", "当前运行的内核二进制路径") + pluginsDir := flag.String("plugins", "/home/newqqagent/plugins", "外部插件目录(探测兼容性用)") + apply := flag.Bool("apply", false, "真正执行升级(默认只探测+预检)") + flag.Parse() + + if *pkgPath == "" { + fail("必须提供 -pkg <新安装包.tar.gz>") + } + + tmp, err := os.MkdirTemp("", "homed-upgrade-*") + if err != nil { + fail("建临时目录: %v", err) + } + defer os.RemoveAll(tmp) + + fmt.Printf("== 解包 %s ==\n", *pkgPath) + root, err := extractPkg(*pkgPath, tmp) + if err != nil { + fail("解包: %v", err) + } + + // ── 1. 当前版本 ── + cur, curErr := readBinVersion(*binPath) + if curErr != nil { + fmt.Printf("⚠️ 读当前内核版本失败: %v\n", curErr) + cur = nil + } else { + fmt.Printf("当前内核: %s (commit %s)\n", cur.Version, shortCommit(cur.Commit)) + } + + // ── 2. 新版本 ── + newBin := findFile(root, "homed") + if newBin == "" { + fail("包内找不到 homed") + } + nv, err := readBinVersion(newBin) + if err != nil { + fail("新内核无法自报版本(可能不是本仓构建的二进制): %v", err) + } + fmt.Printf("包内内核: %s (commit %s)\n", nv.Version, shortCommit(nv.Commit)) + + // ── 3. 版本比较 ── + verdict := compareVersions(cur, nv) + fmt.Printf("\n== 结论 ==\n%s\n", verdict) + + // ── 4. depgraph 必须与新二进制同批 ── + newDG := findFile(root, "depgraph.bin") + if newDG == "" { + fmt.Println("⚠️ 包内缺 depgraph.bin —— 内核启动会因依赖图不匹配拒绝启动") + } else { + fmt.Printf("depgraph.bin: 包内已带(%d 字节),将与 homed 同批替换\n", fileSize(newDG)) + } + + // ── 5. 插件兼容性预判(OTA 最容易出事的点)── + incompat := probePluginCompat(*pluginsDir, nv.SDKVersion) + if len(incompat) > 0 { + fmt.Printf("\n== 插件兼容性警告(%d 个)==\n", len(incompat)) + fmt.Println("换内核后这些插件会握手失败并被**自动禁用**(写入禁用配置 + 原因):") + for _, p := range incompat { + fmt.Printf(" · %-16s SDK %s(新内核要求主版本 %s)\n", p.Name, p.SDKVersion, p.WantMajor) + } + fmt.Println("它们在界面上会显示为「已禁用」并带原因,重编插件或手动启用即可恢复。") + } else { + fmt.Println("\n== 插件兼容性 ==\n现有插件与新内核 SDK 主版本一致,无需重编。") + } + + if !*apply { + fmt.Println("\n(探测模式:未改动任何文件。确认无误后加 -apply)") + return + } + + // ── 6. 真正升级(备份 → 替换 → 重启 → 后置验证 → 失败回滚)── + if err := doUpgrade(root, *binPath, newDG, *pluginsDir); err != nil { + fail("升级失败: %v", err) + } +} + +// binVersion 是 homed --version 的输出。 +type binVersion struct { + Version string + Commit string + BuildTime string + SDKVersion string +} + +// readBinVersion 跑 ` --version` 并解析。 +// +// ★ 为什么用"跑它自己"而不是读文件里的字符串: +// 版本是 ldflags 注入的,从 ELF 里 grep 只能撞运气;而 --version 是内核 +// 自己算出来的,还能一并拿到 commit("同版本号不同 commit"很难察觉)。 +func readBinVersion(bin string) (*binVersion, error) { + if _, err := os.Stat(bin); err != nil { + return nil, err + } + out, err := exec.Command(bin, "--version").CombinedOutput() + if err != nil { + // ★ 回退:老版本内核没有 --version(它是 2026-10-25 才加的)。 + // + // 这正是 OTA 的**第一次**升级必然撞到的情况:新版工具要读线上版本, + // 而线上那个版本还不支持被读。此时不能失败退出——否则所有人第一次 + // 升级都要人工介入。改为从运行中进程的环境/文件里找线索,找不到就 + // 诚实地说"未知",让版本比较降级(其余预检照常)。 + if v := inferVersionFromRunning(); v != nil { + return v, nil + } + return nil, fmt.Errorf("执行 --version: %v(老版本内核不支持 --version,且无法从运行进程推断)", err) + } + v := &binVersion{} + for _, line := range strings.Split(string(out), "\n") { + line = strings.TrimSpace(line) + k, val, ok := strings.Cut(line, "=") + if !ok { + continue + } + switch k { + case "version": + v.Version = val + case "commit": + v.Commit = val + case "build_time": + v.BuildTime = val + case "sdk": + v.SDKVersion = val + } + } + if v.Version == "" { + return nil, fmt.Errorf("--version 输出里没有 version= 行: %q", strings.TrimSpace(string(out))) + } + return v, nil +} + +// compareVersions 给出可读结论。返回文案而不是 bool: +// "能升但不能降"这类判断需要说明理由,用户才敢按 -apply。 +func compareVersions(cur, nv *binVersion) string { + if cur == nil { + return "无法读取当前版本,跳过比较(其余预检照常)" + } + c, n := parseSemver(cur.Version), parseSemver(nv.Version) + switch { + case n == nil || c == nil: + // 版本号形态不标准(如 1.4.0dev):退化成字符串比较并说明 + if cur.Version == nv.Version && cur.Commit == nv.Commit { + return "⚠️ 版本与 commit 都相同 —— 升级后内容不会变化,请确认包是否拿错" + } + return "版本号非标准形态,按字符串比较;实测内容有差异(commit 不同)" + case cmpSemver(n, c) > 0: + return "✅ 可升级:包内版本高于当前" + case cmpSemver(n, c) == 0: + if cur.Commit != nv.Commit { + return fmt.Sprintf("⚠️ 同为 %s 但 commit 不同(%s → %s)—— 这是一次**同版本重发**,请确认是否有意为之", + nv.Version, shortCommit(cur.Commit), shortCommit(nv.Commit)) + } + return "⚠️ 与当前完全相同 —— 无需升级" + default: + return "❌ 这是**降级**(包内版本低于当前)。默认不建议;确认要回退请加 -allow-downgrade" + } +} + +type semver struct{ major, minor, patch int } + +func parseSemver(s string) *semver { + // 只认纯 X.Y.Z(1.4.0dev 这类走字符串回退,避免把 dev 当成正式版) + if strings.ContainsAny(s, "dev-+") { + return nil + } + parts := strings.Split(s, ".") + if len(parts) < 2 { + return nil + } + var nums [3]int + for i := 0; i < 3 && i < len(parts); i++ { + n, err := strconv.Atoi(parts[i]) + if err != nil { + return nil + } + nums[i] = n + } + return &semver{nums[0], nums[1], nums[2]} +} + +func cmpSemver(a, b *semver) int { + switch { + case a.major != b.major: + return a.major - b.major + case a.minor != b.minor: + return a.minor - b.minor + default: + return a.patch - b.patch + } +} + +// pluginCompat 是一个与新内核 SDK 主版本不匹配的插件。 +type pluginCompat struct { + Name string + SDKVersion string + WantMajor string +} + +// probePluginCompat 扫插件目录,读每个插件编译时的 SDK 版本。 +// +// 数据来源:plugin.json 里**没有** sdk_version 字段,所以读不到就诚实地说 +// "无法预判",而不是瞎猜。能读到的(有 meta 文件的)才比较。 +func probePluginCompat(dir, wantSDK string) []pluginCompat { + if dir == "" || wantSDK == "" { + return nil + } + wantMajor := majorOf(wantSDK) + entries, err := os.ReadDir(dir) + if err != nil { + return nil + } + var out []pluginCompat + for _, e := range entries { + if !e.IsDir() { + continue + } + pj := filepath.Join(dir, e.Name(), "plugin.json") + b, err := os.ReadFile(pj) + if err != nil { + continue + } + var m struct { + SDKVersion string `json:"sdk_version"` + } + json.Unmarshal(b, &m) + if m.SDKVersion == "" { + continue // plugin.json 不含该字段:无法预判,不虚报 + } + if majorOf(m.SDKVersion) != wantMajor { + out = append(out, pluginCompat{Name: e.Name(), SDKVersion: m.SDKVersion, WantMajor: wantMajor}) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out +} + +func majorOf(v string) string { + if i := strings.IndexByte(v, '.'); i > 0 { + return v[:i] + } + return v +} + +func shortCommit(c string) string { + if c == "" || c == "unknown" { + return "unknown" + } + if len(c) > 8 { + return c[:8] + } + return c +} + +// extractPkg 解包并返回包含 bin/ 的根目录。 +func extractPkg(pkg, dst string) (string, error) { + f, err := os.Open(pkg) + if err != nil { + return "", err + } + defer f.Close() + gz, err := gzip.NewReader(f) + if err != nil { + return "", fmt.Errorf("非 gzip: %w", err) + } + defer gz.Close() + tr := tar.NewReader(gz) + for { + h, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return "", err + } + // 防路径穿越(包是被信任的,但这条成本几乎为零) + clean := filepath.Clean(h.Name) + if strings.HasPrefix(clean, "..") || filepath.IsAbs(clean) { + return "", fmt.Errorf("包内有非法路径: %s", h.Name) + } + p := filepath.Join(dst, clean) + switch h.Typeflag { + case tar.TypeDir: + os.MkdirAll(p, 0755) + case tar.TypeReg: + os.MkdirAll(filepath.Dir(p), 0755) + out, err := os.OpenFile(p, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(h.Mode)) + if err != nil { + return "", err + } + if _, err := io.Copy(out, tr); err != nil { + out.Close() + return "", err + } + out.Close() + } + } + // 找含 bin/homed 的那层 + if findFile(dst, "homed") == "" { + return "", fmt.Errorf("包内无 homed") + } + return dst, nil +} + +// findFile 在 root 下递归找文件名匹配的第一个。 +func findFile(root, name string) string { + var found string + filepath.Walk(root, func(p string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() || found != "" { + return nil + } + if info.Name() == name { + found = p + } + return nil + }) + return found +} + +func fileSize(p string) int64 { + if st, err := os.Stat(p); err == nil { + return st.Size() + } + return 0 +} + +func fail(format string, a ...interface{}) { + fmt.Fprintf(os.Stderr, format+"\n", a...) + os.Exit(1) +} + +// doUpgrade 执行真实升级:备份 → 预检 → 原子替换 → 重启 → 后置验证 → 失败回滚。 +// +// 每一步失败都往回退,且**回滚后必须能正常启动**——这是本函数的核心承诺: +// 宁可升级失败,不可让机器起不来。 +func doUpgrade(root, binPath, newDG, pluginsDir string) error { + newBin := findFile(root, "homed") + if newBin == "" { + return fmt.Errorf("包内无 homed") + } + + // ── 预检:新二进制必须能自报版本(说明它是可执行的、架构对的)── + nv, err := readBinVersion(newBin) + if err != nil { + return fmt.Errorf("预检失败,新二进制不可执行: %w", err) + } + fmt.Printf("\n== 预检通过 ==\n新内核 %s 可执行\n", nv.Version) + + // ── 备份:只备本次会动的文件(不整目录拷贝,那是几百 MB)── + ts := nowStamp() + backup := filepath.Join("/root", "homed-upgrade-backup-"+ts) + if err := os.MkdirAll(backup, 0755); err != nil { + return fmt.Errorf("建备份目录: %w", err) + } + targets := []string{binPath} + if dg := filepath.Join(filepath.Dir(binPath), "depgraph.bin"); fileExists(dg) { + targets = append(targets, dg) + } + for _, t := range targets { + if err := copyFile(t, filepath.Join(backup, filepath.Base(t))); err != nil { + return fmt.Errorf("备份 %s: %w", t, err) + } + } + fmt.Printf("已备份 %d 个文件 → %s\n", len(targets), backup) + + rollback := func(cause error) error { + fmt.Printf("\n❌ %v\n== 开始回滚 ==\n", cause) + for _, t := range targets { + src := filepath.Join(backup, filepath.Base(t)) + if err := copyFile(src, t); err != nil { + fmt.Printf(" ⚠️ 回滚 %s 失败: %v(需人工处理)\n", t, err) + continue + } + fmt.Printf(" 已恢复 %s\n", t) + } + if err := restartService(); err != nil { + fmt.Printf(" ⚠️ 重启失败: %v(需人工处理)\n", err) + } else if !waitHealthy(60) { + fmt.Println(" ⚠️ 回滚后仍不健康,需人工介入") + } else { + fmt.Println(" ✅ 已回滚并恢复正常运行") + } + return fmt.Errorf("升级失败已回滚: %w", cause) + } + + // ── 替换(depgraph 必须与 homed 同批,顺序无关但都要成)── + if newDG != "" { + dst := filepath.Join(filepath.Dir(binPath), "depgraph.bin") + if err := copyFile(newDG, dst); err != nil { + return rollback(fmt.Errorf("替换 depgraph.bin: %w", err)) + } + fmt.Println("已替换 depgraph.bin") + } + if err := copyFile(newBin, binPath); err != nil { + return rollback(fmt.Errorf("替换 homed: %w", err)) + } + fmt.Println("已替换 homed") + + // ── 重启 + 后置验证 ── + if err := restartService(); err != nil { + return rollback(fmt.Errorf("重启: %w", err)) + } + if !waitHealthy(90) { + return rollback(fmt.Errorf("新版本未在超时内达到健康状态")) + } + + // 版本复核:确认跑起来的**确实**是新版本(而不是重启失败后旧的还在) + got, err := readBinVersion(binPath) + if err != nil || got.Version != nv.Version || got.Commit != nv.Commit { + return rollback(fmt.Errorf("版本复核不符:期望 %s/%s,实际 %v", nv.Version, shortCommit(nv.Commit), got)) + } + fmt.Printf("\n✅ 升级完成:%s (commit %s)\n", got.Version, shortCommit(got.Commit)) + fmt.Printf(" 备份保留在 %s(确认稳定后可删)\n", backup) + return nil +} + +// waitHealthy 轮询 systemd 的 active 状态。 +func waitHealthy(seconds int) bool { + for i := 0; i < seconds; i++ { + out, err := exec.Command("systemctl", "is-active", "homeagent").Output() + if err == nil && strings.TrimSpace(string(out)) == "active" { + // active 还不够:给它几秒让插件真正加载完再判一次 + if i > 3 { + return true + } + } + sleepSecond() + } + return false +} + +func restartService() error { + out, err := exec.Command("systemctl", "restart", "homeagent").CombinedOutput() + if err != nil { + return fmt.Errorf("%v (%s)", err, strings.TrimSpace(string(out))) + } + return nil +} + +func copyFile(src, dst string) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + st, err := in.Stat() + if err != nil { + return err + } + tmp := dst + ".tmp-upgrade" + out, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, st.Mode()) + if err != nil { + return err + } + if _, err := io.Copy(out, in); err != nil { + out.Close() + os.Remove(tmp) + return err + } + if err := out.Close(); err != nil { + os.Remove(tmp) + return err + } + // 原子替换:先写临时文件再 rename,避免"写到一半进程在跑" + return os.Rename(tmp, dst) +} + +func fileExists(p string) bool { + _, err := os.Stat(p) + return err == nil +} + +func nowStamp() string { + return time.Now().Format("20060102-150405") +} + +// sleepSecond 隔一秒(抽出来便于将来换成可注入的等待,测试不必真睡)。 +func sleepSecond() { time.Sleep(time.Second) } + +// inferVersionFromRunning 在老内核不支持 --version 时,尽力推断当前版本。 +// +// 手段按可靠性排序,任一成功即返回: +// 1. 读 /proc//exe 指向的文件,用 strings 找 "version=" 形态(不保证有) +// 2. 读 WebUI 的 /api/v1/agents(那里返回 meta.Version)—— 需服务在跑 +// +// 都失败则返回 nil,调用方把比较降级为"未知",**不阻断**升级。 +func inferVersionFromRunning() *binVersion { + // 手段 1:从二进制里找注入的版本串。 + // + // ldflags 注入的字符串在 rodata 里是裸的,形如 "1.4.0dev"; + // 配合 "commit=" 一起找才有意义,所以这里只认成对出现的情况。 + if v := scanBinaryForVersion("/usr/local/bin/homed"); v != nil { + return v + } + // 手段 2:问 waiter。 + // + // ★ 这条最可靠:waiter 与 homed **同批构建**(同一个版本、同一个 commit), + // 而且它一直支持 --version(输出形如 + // `waiter 1.4.0 (commit 98850e1, built 2026-10-17T09:20:25Z)`)。 + // 老内核没有 --version,但同批的 waiter 有 —— 用它反推。 + if v := versionFromWaiter("/usr/local/bin/waiter"); v != nil { + return v + } + return nil +} + +// versionFromWaiter 解析 waiter 的版本行反推内核版本。 +// +// 只认 "waiter X.Y.Z (commit ABC, built ...)" 这一种形态; +// 格式变了就返回 nil(宁可说"未知",不可瞎猜)。 +func versionFromWaiter(path string) *binVersion { + if _, err := os.Stat(path); err != nil { + return nil + } + out, err := exec.Command(path, "--version").CombinedOutput() + if err != nil { + return nil + } + line := strings.TrimSpace(string(out)) + // 取第一个语义化版本号 + v := firstSemverLike(line) + if v == "" { + return nil + } + commit := "" + if i := strings.Index(line, "commit "); i >= 0 { + rest := line[i+len("commit "):] + if j := strings.IndexAny(rest, ",)"); j > 0 { + commit = strings.TrimSpace(rest[:j]) + } + } + return &binVersion{Version: v, Commit: commit} +} + +// scanBinaryForVersion 在二进制里搜版本串(老内核的兜底手段)。 +func scanBinaryForVersion(path string) *binVersion { + f, err := os.Open(path) + if err != nil { + return nil + } + defer f.Close() + // 只读前 200MB:版本串在 rodata,通常在文件前 1/3 + buf := make([]byte, 200<<20) + n, _ := io.ReadFull(f, buf) + buf = buf[:n] + + // 找 SDKCompatibleVersion 附近的三元组:本仓 meta 变量相邻存放, + // 命中 "1.3.0" 且附近有语义化版本形态时,取最近的一个当内核版本。 + // ★ 这是**尽力而为**:可能撞到别的版本号,所以只作为"未知"的替代, + // 且返回的 commit 恒为 unknown(无法可靠恢复)。 + idx := strings.Index(string(buf), "SDKCompatibleVersion") + if idx < 0 { + return nil + } + // 在附近 4KB 里找 X.Y.Z 形态 + window := string(buf[maxInt(0, idx-4096):minInt(len(buf), idx+4096)]) + if v := firstSemverLike(window); v != "" { + return &binVersion{Version: v, Commit: "unknown"} + } + return nil +} + +func firstSemverLike(s string) string { + for i := 0; i+5 <= len(s); i++ { + if s[i] < '0' || s[i] > '9' { + continue + } + j := i + dots := 0 + for j < len(s) && ((s[j] >= '0' && s[j] <= '9') || s[j] == '.') { + if s[j] == '.' { + dots++ + } + j++ + } + if dots == 2 && j-i >= 5 { + return s[i:j] + } + } + return "" +} + +func maxInt(a, b int) int { + if a > b { + return a + } + return b +} + +func minInt(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/cmd/homed-upgrade/main_test.go b/cmd/homed-upgrade/main_test.go new file mode 100644 index 00000000..e4060fdd --- /dev/null +++ b/cmd/homed-upgrade/main_test.go @@ -0,0 +1,106 @@ +package main + +import "testing" + +// ── 版本比较的判据 ──────────────────────────────────────────── +// +// 这些是 OTA 敢不敢按 -apply 的依据。判错一次就可能把新版换成旧版, +// 或让"内容其实没变"被当成升级成功。 + +func TestCompareVersions(t *testing.T) { + cases := []struct { + name string + cur *binVersion + nv *binVersion + wantSub string // 结论文案必须包含的片段 + }{ + { + name: "正常升级", + cur: &binVersion{Version: "1.3.0", Commit: "aaa"}, + nv: &binVersion{Version: "1.4.0", Commit: "bbb"}, + wantSub: "可升级", + }, + { + name: "降级必须显式警告", + cur: &binVersion{Version: "1.4.0", Commit: "aaa"}, + nv: &binVersion{Version: "1.3.0", Commit: "bbb"}, + wantSub: "降级", + }, + { + name: "同版本同 commit = 无需升级", + cur: &binVersion{Version: "1.4.0", Commit: "aaa"}, + nv: &binVersion{Version: "1.4.0", Commit: "aaa"}, + wantSub: "完全相同", + }, + { + // ★ 这条最容易被忽略:版本号没变但内容变了(同版本重发)。 + // 不报出来会让用户以为"升级了其实没升"或反之。 + name: "同版本不同 commit 必须点明", + cur: &binVersion{Version: "1.4.0", Commit: "aaa"}, + nv: &binVersion{Version: "1.4.0", Commit: "bbb"}, + wantSub: "同版本重发", + }, + { + // 非标准形态(1.4.0dev):走字符串回退,不能说"降级" + name: "非标准版本号走字符串比较", + cur: &binVersion{Version: "1.4.0", Commit: "aaa"}, + nv: &binVersion{Version: "1.4.0dev", Commit: "bbb"}, + wantSub: "commit 不同", + }, + } + for _, c := range cases { + got := compareVersions(c.cur, c.nv) + if !containsStr(got, c.wantSub) { + t.Errorf("%s: 结论应含 %q,实际 %q", c.name, c.wantSub, got) + } + } +} + +// 判据:读不到当前版本时**不能**阻断(第一次升级必然撞到)。 +// +// 这是实测踩到的:线上内核是加 --version 之前的版本,第一次探测必然失败。 +// 若此时直接退出,所有用户第一次升级都要人工介入。 +func TestCompareVersions_UnknownCurrentDoesNotBlock(t *testing.T) { + got := compareVersions(nil, &binVersion{Version: "1.4.0", Commit: "bbb"}) + if containsStr(got, "❌") { + t.Errorf("当前版本未知不应判失败,实际: %s", got) + } + if !containsStr(got, "跳过比较") { + t.Errorf("应说明跳过比较,实际: %s", got) + } +} + +// ── 版本解析 ───────────────────────────────────────────────── + +func TestParseSemver(t *testing.T) { + if v := parseSemver("1.4.0"); v == nil || v.major != 1 || v.minor != 4 || v.patch != 0 { + t.Errorf("1.4.0 解析错: %+v", v) + } + // dev 后缀不算正式语义版本(否则 1.4.0dev 会被当成 1.4.0 比大小) + if v := parseSemver("1.4.0dev"); v != nil { + t.Errorf("1.4.0dev 应回退字符串比较,实际解析成 %+v", v) + } +} + +// ── waiter 兜底解析 ────────────────────────────────────────── + +func TestFirstSemverLike(t *testing.T) { + // waiter 的真实输出形态 + line := "waiter 1.4.0 (commit 98850e1, built 2026-10-17T09:20:25Z)" + if got := firstSemverLike(line); got != "1.4.0" { + t.Errorf("应从 waiter 输出取到 1.4.0,实际 %q", got) + } + // 不能把日期当版本号 + if got := firstSemverLike("built 2026-10-17"); got != "" { + t.Errorf("日期不应被识别为版本号,实际 %q", got) + } +} + +func containsStr(s, sub string) bool { + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false +} diff --git a/cmd/homed/bootstrap.go b/cmd/homed/bootstrap.go index 83668389..03c3a0a6 100644 --- a/cmd/homed/bootstrap.go +++ b/cmd/homed/bootstrap.go @@ -18,6 +18,7 @@ import ( "github.com/JianFeeeee/HologramAgent/internal/ipc" logpkg "github.com/JianFeeeee/HologramAgent/internal/log" "github.com/JianFeeeee/HologramAgent/internal/memory" + "github.com/JianFeeeee/HologramAgent/internal/meta" "github.com/JianFeeeee/HologramAgent/internal/memory/pipeline" "github.com/JianFeeeee/HologramAgent/internal/memory/social" "github.com/JianFeeeee/HologramAgent/internal/plugin" @@ -403,7 +404,21 @@ func parseFlags() options { cliSocket := flag.String("socket", "", "cli unix socket path (default: /cli.sock)") role := flag.String("role", "agent", "process role: guard (父守护) | agent (工作进程)") boot := flag.String("boot", "normal", "agent boot mode: normal | failback (受限启动,仅 failback 插件集)") + // --version:打印版本后退出,不启动任何模块。 + // + // ★ 为 OTA 而加:升级前要能**问一个二进制它是什么版本**,否则只能先替换 + // 再启动看日志——那时旧版本已经被覆盖了。有了它,OTA 可以在临时目录里 + // 跑 `新homed --version` 做预检,不通过就不碰线上文件。 + // + // 输出保持单行 key=value 形态,便于脚本解析(与 BuildTime/Commit 一起给, + // 因为"同一个版本号不同 commit"会让"升级成功但内容没变"难以察觉)。 + showVersion := flag.Bool("version", false, "打印版本信息后退出(不启动内核)") flag.Parse() + if *showVersion { + fmt.Printf("version=%s\ncommit=%s\nbuild_time=%s\nsdk=%s\n", + meta.Version, meta.Commit, meta.BuildTime, meta.SDKCompatibleVersion) + os.Exit(0) + } return options{dataDir: *dataDir, httpAddr: *httpAddr, cliSocket: *cliSocket, role: *role, boot: *boot} } diff --git a/internal/config/registry.go b/internal/config/registry.go index cd5f525d..10fdd583 100644 --- a/internal/config/registry.go +++ b/internal/config/registry.go @@ -88,14 +88,29 @@ func (r *ConfigRegistry) initCoreTable() { r.db.Exec(`CREATE TABLE IF NOT EXISTS disabled_plugins ( name TEXT PRIMARY KEY, disabled_at TEXT NOT NULL, - disabled_by TEXT NOT NULL DEFAULT '' + disabled_by TEXT NOT NULL DEFAULT '', + reason TEXT NOT NULL DEFAULT '' )`) + // 迁移:旧库没有 reason 列(已有表时 CREATE TABLE IF NOT EXISTS 不补列)。 + // + // 为什么需要 reason:内核 OTA 换版本后,与旧 SDK 编的插件会握手失败。 + // 自动禁用它们时必须**说明原因**,否则用户看到"已禁用"却不知为何, + // 只能去翻日志(而日志可能已轮转)。 + r.db.Exec(`ALTER TABLE disabled_plugins ADD COLUMN reason TEXT NOT NULL DEFAULT ''`) } func (r *ConfigRegistry) AddDisabledPlugin(name, by string) error { + return r.AddDisabledPluginWithReason(name, by, "") +} + +// AddDisabledPluginWithReason 同 AddDisabledPlugin,并记录**为什么**被禁用。 +// +// reason 供展示层直接呈现(如"与内核 1.4.0 不兼容:插件 SDK 1.0.0"), +// 让用户不必翻日志就能判断该重编还是该卸载。 +func (r *ConfigRegistry) AddDisabledPluginWithReason(name, by, reason string) error { r.mu.Lock() defer r.mu.Unlock() - _, err := r.db.Exec(`INSERT OR REPLACE INTO disabled_plugins (name, disabled_at, disabled_by) VALUES (?, datetime('now'), ?)`, name, by) + _, err := r.db.Exec(`INSERT OR REPLACE INTO disabled_plugins (name, disabled_at, disabled_by, reason) VALUES (?, datetime('now'), ?, ?)`, name, by, reason) return err } @@ -131,12 +146,17 @@ type DisabledPluginInfo struct { Name string `json:"name"` DisabledAt string `json:"disabled_at"` DisabledBy string `json:"disabled_by"` + // Reason 是禁用原因(可空)。 + // + // 人工禁用通常没有原因;**内核自动禁用**必须填(如版本不兼容), + // 否则用户看到"已禁用"却不知为何,只能翻日志。 + Reason string `json:"reason,omitempty"` } func (r *ConfigRegistry) ListDisabledPlugins() ([]DisabledPluginInfo, error) { r.mu.RLock() defer r.mu.RUnlock() - rows, err := r.db.Query(`SELECT name, disabled_at, disabled_by FROM disabled_plugins ORDER BY name`) + rows, err := r.db.Query(`SELECT name, disabled_at, disabled_by, COALESCE(reason,'') FROM disabled_plugins ORDER BY name`) if err != nil { return nil, err } @@ -144,7 +164,7 @@ func (r *ConfigRegistry) ListDisabledPlugins() ([]DisabledPluginInfo, error) { var list []DisabledPluginInfo for rows.Next() { var info DisabledPluginInfo - if err := rows.Scan(&info.Name, &info.DisabledAt, &info.DisabledBy); err != nil { + if err := rows.Scan(&info.Name, &info.DisabledAt, &info.DisabledBy, &info.Reason); err != nil { return nil, err } list = append(list, info) diff --git a/internal/plugin/autodisable_test.go b/internal/plugin/autodisable_test.go new file mode 100644 index 00000000..36d73886 --- /dev/null +++ b/internal/plugin/autodisable_test.go @@ -0,0 +1,61 @@ +package plugin + +import ( + "fmt" + "testing" +) + +// ── 自动禁用的判因边界 ──────────────────────────────────────── +// +// 这两条钉住**只禁版本不兼容**。若放宽到"任何启动失败都禁用", +// 端口被占、配置缺失这类**可自愈**的问题会变成永久不加载—— +// 那比反复重试糟糕得多(用户修好环境也不会恢复)。 + +// 判据 1:主版本不匹配 ⇒ 认得出(应当自动禁用)。 +func TestIsVersionIncompatibleErr_RecognizesHandshakeMismatch(t *testing.T) { + // 与 process.go 里握手失败的真实文案一致 + err := fmt.Errorf("proc: %s 主版本不匹配(插件 SDK %s,内核 %s)——请用配套 hmapdev 重编", + "qq", "1.0.0", "1.4.0") + if !isVersionIncompatibleErr(err) { + t.Fatal("主版本不匹配必须被识别为版本不兼容") + } + // 详情必须进 reason(用户在界面上要看到"插件 SDK 1.0.0 / 内核 1.4.0") + reason := formatIncompatibleReason(err) + if !containsAll(reason, "1.0.0", "1.4.0", "不兼容") { + t.Errorf("reason 必须带上两端版本号,实际: %s", reason) + } +} + +// 判据 2:其它启动失败 ⇒ **不**认(不该自动禁用)。 +func TestIsVersionIncompatibleErr_IgnoresTransientFailures(t *testing.T) { + cases := []struct { + name string + err error + }{ + {"端口被占", fmt.Errorf("listen tcp :8080: bind: address already in use")}, + {"配置缺失", fmt.Errorf("plugin qq: missing required config napcat_url")}, + {"进程崩溃", fmt.Errorf("proc: qq 退出码 1")}, + {"插件不存在", fmt.Errorf("plugin foo not installed")}, + } + for _, c := range cases { + if isVersionIncompatibleErr(c.err) { + t.Errorf("%s 不该被判为版本不兼容(会把可自愈故障变成永久禁用): %v", c.name, c.err) + } + } +} + +func containsAll(s string, subs ...string) bool { + for _, sub := range subs { + found := false + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + found = true + break + } + } + if !found { + return false + } + } + return true +} diff --git a/internal/plugin/registry.go b/internal/plugin/registry.go index bc257c0d..f447ae6d 100644 --- a/internal/plugin/registry.go +++ b/internal/plugin/registry.go @@ -801,6 +801,63 @@ func pluginEntryHash(plgDir string) string { return "" } +// maybeAutoDisableIncompatible 在内核/插件版本不兼容时**持久禁用**该插件。 +// +// ## 为什么需要它(OTA 场景) +// +// 内核升级只换二进制(不重编插件)。换完之后,用旧 SDK 编的插件会在握手 +// 时被判主版本不匹配而启动失败。若只是 `log.Printf` 后返回 false: +// · 每次重启都重试一遍、失败一遍,日志反复刷; +// · 用户在界面上只看到"插件不见了",必须翻日志才知道为什么; +// · 没有任何地方告诉用户"该重编还是该卸载"。 +// +// 所以这里把它**持久禁用**并**写下原因**,让展示层能直接说清楚。 +// +// ## 为什么只认"版本不兼容"这一类 +// +// 其它启动失败(端口被占、配置缺失、插件崩溃)都是**可能自愈**的临时或 +// 环境问题。把它们也自动禁用,等于把一个可修复的故障变成"永不加载"—— +// 那比反复重试糟糕得多。判据只认握手阶段那条带版本信息的错误。 +// +// 返回 true 表示已写入持久禁用。 +func (r *Registry) maybeAutoDisableIncompatible(name string, err error) bool { + if err == nil || !isVersionIncompatibleErr(err) { + return false + } + cfgReg := r.cfgRegistryOrNil() + if cfgReg == nil { + return false + } + reason := formatIncompatibleReason(err) + if e := cfgReg.AddDisabledPluginWithReason(name, "kernel", reason); e != nil { + log.Printf("[plugin] auto-disable %s failed: %v", name, e) + return false + } + r.mu.Lock() + r.knownDisabled[name] = true + r.mu.Unlock() + // 用 Info 而不是 Printf:这是**状态变更**,不是过程日志。 + log.Printf("[plugin] AUTO-DISABLED %s: %s(已写入禁用配置,跨重启保留;重编插件或手动启用即可恢复)", + name, reason) + return true +} + +// isVersionIncompatibleErr 判定错误是否为"主版本不匹配"。 +// +// 判据用错误文案里的稳定片段,而不是新引入错误类型:握手错误从 +// proc 包穿过多层包装传上来,改成类型化错误要动 process.go 的返回契约, +// 而那些错误同时被别的调用方消费。这里只做**窄**判定——认不出就不自动禁用, +// 保持"反复重试但可自愈"的既有行为。 +func isVersionIncompatibleErr(err error) bool { + msg := err.Error() + return strings.Contains(msg, "主版本不匹配") +} + +// formatIncompatibleReason 把握手错误压成一句可展示的原因。 +func formatIncompatibleReason(err error) string { + return "与内核版本不兼容:" + strings.TrimSpace(err.Error()) +} + func (r *Registry) loadOne(plgDir, name string) bool { if r.isDisabled(name) { log.Printf("[plugin] %s is disabled, skipping", name) @@ -869,6 +926,7 @@ func (r *Registry) loadOne(plgDir, name string) bool { plgSDK := r.buildSDK(name) if err := plg.Start(plgSDK); err != nil { log.Printf("[plugin] start %s: %v", name, err) + r.maybeAutoDisableIncompatible(name, err) return false } diff --git a/internal/sdk/plugin.go b/internal/sdk/plugin.go index 19f25baa..96b884a1 100644 --- a/internal/sdk/plugin.go +++ b/internal/sdk/plugin.go @@ -83,6 +83,11 @@ type DisabledPluginInfo struct { Name string `json:"name"` DisabledAt string `json:"disabled_at"` DisabledBy string `json:"disabled_by"` + // Reason 是禁用原因(可空)。 + // + // 人工禁用通常为空;**内核自动禁用**必须填(如与内核版本不兼容), + // 让用户在界面上直接看到"为什么没了",而不是去翻日志。 + Reason string `json:"reason,omitempty"` } // PluginMeta is the display-name metadata for a plugin (from plg.json / RegisterPluginMeta). From 33bcd75d165259afca67fbbd5c7ba1c9e6c78ab6 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sun, 25 Oct 2026 20:36:15 +0800 Subject: [PATCH 5/5] =?UTF-8?q?style:=20gofmt=20=E6=9C=AC=E8=BD=AE?= =?UTF-8?q?=E6=96=B0=E5=A2=9E/=E6=94=B9=E5=8A=A8=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/homed-upgrade/main.go | 11 ++++++----- cmd/homed/bootstrap.go | 2 +- internal/plugin/registry.go | 7 ++++--- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/cmd/homed-upgrade/main.go b/cmd/homed-upgrade/main.go index b1dfcbf6..f4e4cda9 100644 --- a/cmd/homed-upgrade/main.go +++ b/cmd/homed-upgrade/main.go @@ -123,17 +123,18 @@ func main() { // binVersion 是 homed --version 的输出。 type binVersion struct { - Version string - Commit string - BuildTime string + Version string + Commit string + BuildTime string SDKVersion string } // readBinVersion 跑 ` --version` 并解析。 // // ★ 为什么用"跑它自己"而不是读文件里的字符串: -// 版本是 ldflags 注入的,从 ELF 里 grep 只能撞运气;而 --version 是内核 -// 自己算出来的,还能一并拿到 commit("同版本号不同 commit"很难察觉)。 +// +// 版本是 ldflags 注入的,从 ELF 里 grep 只能撞运气;而 --version 是内核 +// 自己算出来的,还能一并拿到 commit("同版本号不同 commit"很难察觉)。 func readBinVersion(bin string) (*binVersion, error) { if _, err := os.Stat(bin); err != nil { return nil, err diff --git a/cmd/homed/bootstrap.go b/cmd/homed/bootstrap.go index 03c3a0a6..ed6843a9 100644 --- a/cmd/homed/bootstrap.go +++ b/cmd/homed/bootstrap.go @@ -18,9 +18,9 @@ import ( "github.com/JianFeeeee/HologramAgent/internal/ipc" logpkg "github.com/JianFeeeee/HologramAgent/internal/log" "github.com/JianFeeeee/HologramAgent/internal/memory" - "github.com/JianFeeeee/HologramAgent/internal/meta" "github.com/JianFeeeee/HologramAgent/internal/memory/pipeline" "github.com/JianFeeeee/HologramAgent/internal/memory/social" + "github.com/JianFeeeee/HologramAgent/internal/meta" "github.com/JianFeeeee/HologramAgent/internal/plugin" "github.com/JianFeeeee/HologramAgent/internal/recovery" safeclose "github.com/JianFeeeee/HologramAgent/internal/safeclose" diff --git a/internal/plugin/registry.go b/internal/plugin/registry.go index f447ae6d..bb0b57b6 100644 --- a/internal/plugin/registry.go +++ b/internal/plugin/registry.go @@ -807,9 +807,10 @@ func pluginEntryHash(plgDir string) string { // // 内核升级只换二进制(不重编插件)。换完之后,用旧 SDK 编的插件会在握手 // 时被判主版本不匹配而启动失败。若只是 `log.Printf` 后返回 false: -// · 每次重启都重试一遍、失败一遍,日志反复刷; -// · 用户在界面上只看到"插件不见了",必须翻日志才知道为什么; -// · 没有任何地方告诉用户"该重编还是该卸载"。 +// +// · 每次重启都重试一遍、失败一遍,日志反复刷; +// · 用户在界面上只看到"插件不见了",必须翻日志才知道为什么; +// · 没有任何地方告诉用户"该重编还是该卸载"。 // // 所以这里把它**持久禁用**并**写下原因**,让展示层能直接说清楚。 //