feat(scheduler): L4 也归内核级插件 —— WebUI 终止按钮可用“立即打断”

上一提交把 L4 写成“内核独占(panic / selfip)”,漏了内核级插件这类来源。
用户澄清:**内核级插件应当能声明 L4,用于实现中断能力**,例如 WebUI 的终止按钮。

判据(两道闸,纵深防御):
  1. proc 桥(外部进程唯一入口)一律把 L4 夹到 L3。在这里夹而不是只按 source 判,
     是因为 source 是插件自报字段、可以冒名;本函数所在位置能确知“来自外部进程”。
  2. core:isKernelLevelSource(source) 查 pluginReg.IsBuiltinPlugin,只有编译期内置
     插件(init() 自注册的工厂)才承认 L4。
source 约定 `插件名` 或 `插件名/实例`(webui/<deviceID>),判据取第一段——
否则带设备身份的 WebUI 来源会被误判成外部插件而拿不到 L4。

改动:
- core: interruptLevel(evt, privileged bool);新增 isKernelLevelSource;
  requestPreempt 不再夹取(级别已由 interruptLevel 解析,否则内核级插件的 L4 被削掉)。
- eventloop: 传入 a.isKernelLevelSource(evt.Source)。
- proc 桥: 新增 clampExternalPriority,pubSdkInjectOpts 一律夹取。
- internal/sdk: 再导出 PriorityL1..L4(内置插件用 sdk.PriorityL4)。
- webui handleChatInterrupt(终止按钮)声明 PriorityL4。
- timer 声明 PriorityL3:定时器是“时钟那种实时工作”,比 QQ 那类可无限等待的
  异步消息高(L1)——这是对用户“它不是时钟那种实时工作”的直接推论,可改。
- 测试: L4 特权矩阵(非特权夹取 / 特权承认)、source 判据(内置、内置/实例、
  外部、空、前缀不误匹配)、内核级插件 L4 一路到达调度器、proc 夹取两条。
- 设计稿 §2/§3.2/§11.1/§14/§15 按“L4 = 内核 + 内核级插件”更正。

验收:go build/vet 干净;go test ./... 37 包 ok 0 FAIL;-race 全绿(含 webui/timer)。
This commit is contained in:
JianFeeeee
2026-09-13 07:18:03 +08:00
parent 2be407b7b2
commit 96e0da4a11
10 changed files with 260 additions and 38 deletions

View File

@ -15,12 +15,14 @@ import (
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
)
// 插件声明 L4 必须被夹到 L3:L4 是内核的调度内部属性,不接受外部越权。
func TestKernel_PluginCannotClaimL4(t *testing.T) {
// 非内核级来源声明 L4 必须被夹到 L3;内核级来源(内置插件)可用到 L4。
func TestKernel_L4RequiresKernelLevelSource(t *testing.T) {
if got := clampPluginLevel(LevelCritical); got != LevelInteractive {
t.Fatalf("插件声明 L4 应被夹到 L3,实际 %v", got)
t.Fatalf("非特权声明 L4 应被夹到 L3,实际 %v", got)
}
cases := []struct {
declared string
@ -30,7 +32,7 @@ func TestKernel_PluginCannotClaimL4(t *testing.T) {
{"L2", LevelMessage},
{"L3", LevelInteractive},
{"l2", LevelMessage},
{"L4", LevelInteractive}, // 越权 → 夹到 L3
{"L4", LevelInteractive}, // 非特权 → 夹到 L3
{"L7", DefaultLevel}, // 未知 → 默认级
{"", DefaultLevel}, // 未声明 → 默认级
{"紧急", DefaultLevel}, // 拼写错误 → 默认级(不得被静默当成别的级别)
@ -40,13 +42,62 @@ func TestKernel_PluginCannotClaimL4(t *testing.T) {
if c.declared != "" {
evt.Payload["priority"] = c.declared
}
if got := interruptLevel(evt); got != c.want {
t.Fatalf("声明 %q → 级别 %v,期望 %v", c.declared, got, c.want)
if got := interruptLevel(evt, false); got != c.want {
t.Fatalf("非特权声明 %q → 级别 %v,期望 %v", c.declared, got, c.want)
}
}
if got := interruptLevel(nil); got != DefaultLevel {
if got := interruptLevel(nil, false); got != DefaultLevel {
t.Fatalf("无事件应为默认级,实际 %v", got)
}
// 特权(内核级插件):L4 被承认,其余待遇不变。
for _, c := range []struct {
declared string
want Level
}{
{"L4", LevelCritical},
{"L3", LevelInteractive},
{"L1", LevelBackground},
{"", DefaultLevel},
{"L9", DefaultLevel},
} {
evt := &agentIO.InputEvent{Payload: map[string]interface{}{}}
if c.declared != "" {
evt.Payload["priority"] = c.declared
}
if got := interruptLevel(evt, true); got != c.want {
t.Fatalf("特权声明 %q → 级别 %v,期望 %v", c.declared, got, c.want)
}
}
}
// 内核级 = 插件注册表里的**内置工厂**(编译期自注册),与插件自报名无关;
// source 约定 `插件名` 或 `插件名/实例`(如 webui/<deviceID>)。
func TestKernel_KernelLevelSource(t *testing.T) {
plugin.RegisterFactory("core_test_builtin", func(string, map[string]interface{}) (sdk.Plugin, error) {
return nil, nil
})
a := &Agent{pluginReg: plugin.NewRegistry()}
cases := []struct {
source string
want bool
}{
{"core_test_builtin", true},
{"core_test_builtin/dev-1", true}, // 插件名/实例
{"core_test_external", false},
{"webui", false}, // 本测试注册表里没有 webui 工厂
{"", false},
{"core_test_builtinX", false}, // 不做前缀匹配
}
for _, c := range cases {
if got := a.isKernelLevelSource(c.source); got != c.want {
t.Fatalf("source=%q → %v,期望 %v", c.source, got, c.want)
}
}
if (&Agent{}).isKernelLevelSource("core_test_builtin") {
t.Fatal("没有插件注册表时不得授予内核级")
}
}
// 排队任务可被**任何**中断打断——包括最低的 L1。
@ -178,10 +229,10 @@ func TestKernel_PriorityFlowsThroughIOLayer(t *testing.T) {
select {
case evt := <-ioM.InputInterruptChan():
if got := interruptLevel(evt); got != LevelInteractive {
if got := interruptLevel(evt, false); got != LevelInteractive {
t.Fatalf("经 io 层后的级别=%v,期望 L3(payload=%v)", got, evt.Payload)
}
task := newInterruptTask(evt, interruptLevel(evt))
task := newInterruptTask(evt, interruptLevel(evt, false))
if task.Class != TaskInterrupt || task.Level != LevelInteractive {
t.Fatalf("中断任务类别/级别=%v/%v,期望 interrupt/L3", task.Class, task.Level)
}
@ -204,3 +255,40 @@ func TestKernel_PriorityFlowsThroughIOLayer(t *testing.T) {
t.Fatal("排队输入未到达 inputCh")
}
}
// 内核级插件声明的 L4 必须一路到达调度器(“立即打断”能力,如 WebUI 终止按钮)。
func TestKernel_KernelLevelPluginCanRaiseL4(t *testing.T) {
plugin.RegisterFactory("core_test_l4", func(string, map[string]interface{}) (sdk.Plugin, error) {
return nil, nil
})
a := newPreemptAgent(t, &scriptProvider{})
a.pluginReg = plugin.NewRegistry()
// 先让一个排队任务跑起来(无级别),才能看到“抢占”。
evt, _ := textEvent("qq", "长任务")
if !a.sched.enqueue(newInputTask(evt)) {
t.Fatal("入队失败")
}
a.sched.nextRef()
// 内核级插件(内置)声明 L4 的终止通知。
kevt, _ := textEvent("core_test_l4", "用户按了终止按钮")
kevt.Payload["priority"] = "L4"
level := interruptLevel(kevt, a.isKernelLevelSource(kevt.Source))
if level != LevelCritical {
t.Fatalf("内核级插件声明 L4 应得 L4,实际 %v", level)
}
if !a.sched.requestPreempt(kevt, level) {
t.Fatal("L4 应能打断排队任务")
}
if a.sched.immediate == nil || a.sched.immediate.Level != LevelCritical {
t.Fatalf("应有一条 L4 中断在 immediate,实际 %+v", a.sched.immediate)
}
// 反例:同样的声明来自外部插件 → 夹到 L3。
eevt, _ := textEvent("core_test_external", "外部插件也想立即打断")
eevt.Payload["priority"] = "L4"
if got := interruptLevel(eevt, a.isKernelLevelSource(eevt.Source)); got != LevelInteractive {
t.Fatalf("外部插件声明 L4 应被夹到 L3,实际 %v", got)
}
}