fix(offload): 内核说明不能被再转投(自我循环)+ offload_owned 未接线

★ 线上实测两个缺陷:

1. **自我循环**:转投会在队列留一条 [系统] 说明(source=kernel),
   而转投条件把这条说明也算进「积压够了」⇒ 每次转投都产生下一轮要转投的东西。
   实测 5 秒内连续触发两次,分诊助手不断收到「N 条积压已转投」这类噪音。
   修法:takeQueuedInputs 排除 isKernelNotice(source=kernel)。

2. **offload_owned 永远为 false**:我加了 ResidentInfo 字段、加了状态面映射,
   却漏了在 rc.info() 里赋值 ⇒ 线上转投子明明存在,读出来是 null。
   这类「加了字段但没接线」不会报错,只会让父的判断悄悄失效
   (父据此决定回收策略,读到 false 就会把临时助手当成正式子)。

测试 +3:说明不转投 / 循环必须终止 / offload_owned 会被上报。
前两条已实测「禁用守卫会失败、恢复后通过」,是真回归测试。
This commit is contained in:
JianFeeeee
2026-09-19 17:47:44 +08:00
parent 943eef01cf
commit 8acd3ce1a8
3 changed files with 138 additions and 0 deletions

View File

@ -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)
}
}

View File

@ -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

View File

@ -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-195 秒内连续触发两次,子侧不断收到这类噪音。
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
}