From 8acd3ce1a82bedba5fc9e3ab0ad01f6eeb07a748 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sat, 19 Sep 2026 17:47:44 +0800 Subject: [PATCH] =?UTF-8?q?fix(offload):=20=E5=86=85=E6=A0=B8=E8=AF=B4?= =?UTF-8?q?=E6=98=8E=E4=B8=8D=E8=83=BD=E8=A2=AB=E5=86=8D=E8=BD=AC=E6=8A=95?= =?UTF-8?q?=EF=BC=88=E8=87=AA=E6=88=91=E5=BE=AA=E7=8E=AF=EF=BC=89+=20offlo?= =?UTF-8?q?ad=5Fowned=20=E6=9C=AA=E6=8E=A5=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ★ 线上实测两个缺陷: 1. **自我循环**:转投会在队列留一条 [系统] 说明(source=kernel), 而转投条件把这条说明也算进「积压够了」⇒ 每次转投都产生下一轮要转投的东西。 实测 5 秒内连续触发两次,分诊助手不断收到「N 条积压已转投」这类噪音。 修法:takeQueuedInputs 排除 isKernelNotice(source=kernel)。 2. **offload_owned 永远为 false**:我加了 ResidentInfo 字段、加了状态面映射, 却漏了在 rc.info() 里赋值 ⇒ 线上转投子明明存在,读出来是 null。 这类「加了字段但没接线」不会报错,只会让父的判断悄悄失效 (父据此决定回收策略,读到 false 就会把临时助手当成正式子)。 测试 +3:说明不转投 / 循环必须终止 / offload_owned 会被上报。 前两条已实测「禁用守卫会失败、恢复后通过」,是真回归测试。 --- internal/agent/core/offload_test.go | 116 ++++++++++++++++++++++++++++ internal/agent/core/resident.go | 3 + internal/agent/core/scheduler.go | 19 +++++ 3 files changed, 138 insertions(+) diff --git a/internal/agent/core/offload_test.go b/internal/agent/core/offload_test.go index 6516b1a..1c46a18 100644 --- a/internal/agent/core/offload_test.go +++ b/internal/agent/core/offload_test.go @@ -580,3 +580,119 @@ func TestResidualEmptyIsReported(t *testing.T) { t.Errorf("应报告无残余任务,实际 n=%d msg=%q", n, msg) } } + +// ★ 回归:offload_owned 必须真的**被填上**。 +// +// 我第一版加了字段、加了状态面映射,却漏了在 rc.info() 里赋值 ⇒ 父读到的 +// 永远是 false(实测:线上转投子明明存在,offload_owned 却是 null)。 +// 这类"加了字段但没接线"的缺陷不会报错,只会让上层判断悄悄失效。 +func TestOffloadOwnedIsReported(t *testing.T) { + root, main := newRootWithoutSchedulerLoop(t) + defer main.Close() + + // 人工建的子(offload_owned 应为 false) + manual, err := root.SpawnResident(ResidentOptions{ + ID: "manual-child", TempPath: root.residentTempPath("manual-child"), + }) + if err != nil { + t.Fatalf("创建驻留子失败: %v", err) + } + if manual.OffloadOwned { + t.Error("人工创建的子不应被标记为 offload_owned") + } + + // 内核为转投拉起的子(应为 true) + opts := DefaultOffloadOptions() + opts.Enabled = true + opts.BusyAfter = time.Nanosecond + opts.MinPending = 1 + opts.MaxResidents = 1 + root.sched.enqueue(makeQueuedInput(1)) + root.sched.nextRef() + root.sched.enqueue(makeQueuedInput(2)) + if n := root.offloadPendingTasks(opts); n != 1 { + t.Fatalf("应转投 1 条,实际 %d", n) + } + + list := root.Residents() + var foundOffload *ResidentInfo + for i := range list { + if strings.HasPrefix(list[i].ID, "offload-") { + foundOffload = &list[i] + } + } + if foundOffload == nil { + t.Fatal("未找到转投子") + } + if !foundOffload.OffloadOwned { + t.Error("★ 转投子必须被标记 offload_owned=true(父据此决定回收策略)") + } +} + +// ★★ 回归:内核自己留的说明**不能再被转投**,否则自我循环。 +// +// 我第一版漏了这一步:转投会在队列里留一条 [系统] 说明(source=kernel), +// 而转投条件("排队输入够了")又会把这条说明算进去 ⇒ 每次转投都产生下一轮 +// 要转投的东西。实测:5 秒内连续触发两次,分诊助手不断收到这类噪音。 +func TestKernelNoticeIsNeverOffloaded(t *testing.T) { + root, main := newRootWithoutSchedulerLoop(t) + defer main.Close() + + opts := DefaultOffloadOptions() + opts.Enabled = true + opts.BusyAfter = time.Nanosecond + opts.MinPending = 1 + opts.MaxResidents = 1 + + root.sched.enqueue(makeQueuedInput(1)) + root.sched.nextRef() + + // 队列里放一条"内核说明"+ 一条真实积压 + root.sched.enqueue(newInputTask(root.syntheticEvent("2 条积压已交由临时助手分诊"))) + root.sched.enqueue(makeQueuedInput(2)) + + moved := root.offloadPendingTasks(opts) + if moved != 1 { + t.Fatalf("★ 只应转投真实积压 1 条(说明不可转投),实际 %d", moved) + } + // 说明必须还在队列里(留给主 agent 看),不能被搬走 + var noticeLeft bool + for _, task := range root.sched.queue { + if task.Event != nil && isKernelNotice(task.Event) { + noticeLeft = true + } + } + if !noticeLeft { + t.Error("内核说明应留在队列里给主 agent 看,不应被转投走") + } +} + +// 转投自身产生的说明也不能构成下一轮的积压(循环必须终止)。 +func TestOffloadDoesNotLoopOnOwnNotice(t *testing.T) { + root, main := newRootWithoutSchedulerLoop(t) + defer main.Close() + + opts := DefaultOffloadOptions() + opts.Enabled = true + opts.BusyAfter = time.Nanosecond + opts.MinPending = 2 + opts.MaxResidents = 1 + + root.sched.enqueue(makeQueuedInput(1)) + root.sched.nextRef() + root.sched.enqueue(makeQueuedInput(2)) + root.sched.enqueue(makeQueuedInput(3)) + + if n := root.offloadPendingTasks(opts); n != 2 { + t.Fatalf("第一轮应转 2 条,实际 %d", n) + } + // 再调若干次:队列里只剩一条说明,不够 MinPending ⇒ 不该再转 + for i := 0; i < 5; i++ { + if n := root.offloadPendingTasks(opts); n != 0 { + t.Fatalf("第 %d 次仍在转投(自我循环):转了 %d 条", i+1, n) + } + } + if got := len(root.Residents()); got != 1 { + t.Errorf("不应反复拉起新子,实际 %d 个", got) + } +} diff --git a/internal/agent/core/resident.go b/internal/agent/core/resident.go index 620c034..76e431b 100644 --- a/internal/agent/core/resident.go +++ b/internal/agent/core/resident.go @@ -662,6 +662,9 @@ func (rc *residentChild) info() ResidentInfo { // 注意它**不等于** len(table):处理表记的是"当前上下文窗口内"的轮次, // 压缩会清空(§8.3),所以窗口内的条数会被重置,而轮次总数不会。 Rounds: rc.agent.roundsExecuted(), + // OffloadOwned:让父分清"我建的子"与"内核临时拉的分诊助手"。 + // 父据此决定回收策略(临时的可以空闲时回收,正式的按需保留)。 + OffloadOwned: rc.offloadOwned, } if len(table) > 0 { info.Table = table diff --git a/internal/agent/core/scheduler.go b/internal/agent/core/scheduler.go index bd418f4..b7133b9 100644 --- a/internal/agent/core/scheduler.go +++ b/internal/agent/core/scheduler.go @@ -454,6 +454,14 @@ func (s *scheduler) takeQueuedInputs(min int) []offloadCandidate { if t == nil || t.Class != TaskQueued || t.Kind != TaskKindInput || t.Event == nil { continue } + // ❗内核自己造的说明(source=kernel)**不能再被转投**。 + // + // 它本来就是"告知这条信息不用再处理"的通知,而转投又会在队列里留下 + // 一条新的同款通知 ⇒ 自我循环:每次转投都产生下一轮要转投的东西。 + // 实测(2026-09-19):5 秒内连续触发两次,子侧不断收到这类噪音。 + if isKernelNotice(t.Event) { + continue + } out = append(out, offloadCandidate{Event: t.Event}) if len(out) >= min { break @@ -1377,3 +1385,14 @@ func (a *Agent) resumeTask(t *Task, f *TaskFrame) { a.finishInputTask(f, out) a.sched.done(t) } + +// kernelNoticeSource 是内核自己造的输入事件的来源名(见 offload.syntheticEvent)。 +// +// 它存在的主要理由是**可被识别**:转投等机制必须能排除"自己造的说明", +// 否则会在队列里自我循环(说明 → 被转投 → 产生新说明 → …)。 +const kernelNoticeSource = "kernel" + +// isKernelNotice 判断一条输入是不是内核自己造的通知。 +func isKernelNotice(evt *agentIO.InputEvent) bool { + return evt != nil && evt.Source == kernelNoticeSource +}