Files
HomeAgent/internal/agent/core/scheduler_kernel_test.go
JianFeeeee cb32032f76 feat(scheduler)!: 中断/排队两类别模型 + 插件声明 L1-L3、L4 内核独占
用户澄清推翻了早期设计的三处前提,本提交按新模型重做调度核心(行为有意变化):

1) 类别由注入 API 决定,与通道名无关
   - InjectInterrupt*                      -> TaskInterrupt(带级别,可被严格更高级中断打断)
   - InjectText*/InjectInputSync*/内核自循环 -> TaskQueued(无级别,可被任何中断打断)
   - 删除按通道名推断的 taskLevel():qq 走 InjectInterruptTextOpts,本就是中断

2) 级别只属于中断
   - 插件在 InjectOptions.Priority 声明 L1-L3(空/非法降级 L1,声明 L4 夹到 L3)
   - L4 内核独占:新增 raiseKernelInterrupt(panic / selfip);requestKernelPreempt 不夹取
   - panic 现在产生一条带 kernel 标记的 L4 中断;L4 自身 panic 不再产生新 L4(防自我放大)

3) 选择结构:四容器固定次序,删除统一比较器
   - immediate(抢占者立即运行)-> 中断队列 L4..L1 -> 栈顶(与队头比级别) -> 排队 FIFO
   - 删除 pickTaskIndex/taskBefore 与“同级 pending 优先”补丁(根因是抢占者进了队列)
   - 中断栈上界改为结构推论 = 4(= 中断级数);删除“超限转 pendingInterrupts”降级

公开 SDK(feature 分支有意新增,纯追加):InjectOptions.Priority + PriorityL1/2/3;
内核 io / proc 桥 / 插件模板同步透传。

设计稿 §2/§3/§4.1/§6.3/§9/§11/§12/§13/§15 按新模型重写。

验收:go build/vet 干净;go test ./... 37 包 ok 0 FAIL;-race 全绿;
e2e(抢占-挂起-恢复)+ 压力(200 排队 + 50 中断,L1/L2/L3 轮转)通过。
2026-09-13 07:00:51 +08:00

207 lines
7.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package core
// L4 的内核独占性 + 两类别抢占规则。
//
// 模型(用户明确):
// - 类别由**用哪个注入 API** 决定,与通道名无关;
// - L1..L3 由插件在 InjectOptions.Priority 声明;
// - L4 只有内核持有panic / 内核事件 selfip
// - 排队输入无级别,可被**任何**中断打断。
import (
"strings"
"testing"
"time"
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
)
// 插件声明 L4 必须被夹到 L3L4 是内核的调度内部属性,不接受外部越权。
func TestKernel_PluginCannotClaimL4(t *testing.T) {
if got := clampPluginLevel(LevelCritical); got != LevelInteractive {
t.Fatalf("插件声明 L4 应被夹到 L3实际 %v", got)
}
cases := []struct {
declared string
want Level
}{
{"L1", LevelBackground},
{"L2", LevelMessage},
{"L3", LevelInteractive},
{"l2", LevelMessage},
{"L4", LevelInteractive}, // 越权 → 夹到 L3
{"L7", DefaultLevel}, // 未知 → 默认级
{"", DefaultLevel}, // 未声明 → 默认级
{"紧急", DefaultLevel}, // 拼写错误 → 默认级(不得被静默当成别的级别)
}
for _, c := range cases {
evt := &agentIO.InputEvent{Payload: map[string]interface{}{}}
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(nil); got != DefaultLevel {
t.Fatalf("无事件应为默认级,实际 %v", got)
}
}
// 排队任务可被**任何**中断打断——包括最低的 L1。
func TestKernel_QueuedTaskIsPreemptedByAnyInterrupt(t *testing.T) {
s := newScheduler(8)
q := newInputTask(&agentIO.InputEvent{Source: "plugin", OutputChannel: "plugin"})
s.enqueue(q)
if task, _, kind := s.nextRef(); task != q || kind != nextReady {
t.Fatalf("应取到排队任务kind=%v", kind)
}
evt, _ := textEvent("qq", "最低级中断")
if !s.registerInterrupt(newInterruptTask(evt, LevelBackground)) {
t.Fatal("L1 中断也必须能打断排队任务(排队任务无级别)")
}
if s.immediate == nil || s.immediate.Level != LevelBackground {
t.Fatalf("抢占者应进 immediate 槽,实际 %+v", s.immediate)
}
}
// 排队输入从不抢占——它没有级别,也就没有“比谁高”。
func TestKernel_QueuedInputNeverPreempts(t *testing.T) {
s := newScheduler(8)
if !s.enqueue(newInputTask(&agentIO.InputEvent{Source: "a", OutputChannel: "a"})) {
t.Fatal("入队失败")
}
s.nextRef() // running = 第一个排队任务
if s.registerInterrupt(newInputTask(&agentIO.InputEvent{Source: "b", OutputChannel: "b"})) {
t.Fatal("排队输入不得抢占任何任务")
}
}
// 内核 L4 入口不受夹取影响且能抢占中断L3
func TestKernel_RequestKernelPreemptUsesL4(t *testing.T) {
s := newScheduler(8)
evt, _ := textEvent("cli", "L3 运行中")
s.registerInterrupt(newInterruptTask(evt, LevelInteractive))
s.nextRef() // running = L3 中断
kevt, _ := textEvent("kernel", "panic 中断")
if !s.requestKernelPreempt(kevt) {
t.Fatal("内核 L4 应能抢占 L3 中断")
}
if s.immediate == nil || s.immediate.Level != LevelCritical {
t.Fatalf("内核中断必须是 L4实际 %+v", s.immediate)
}
}
// 任务 panic → 内核 L4 中断panic 是 L4 的来源之一)。
func TestKernel_PanicRaisesL4Interrupt(t *testing.T) {
sp := &scriptProvider{script: []*agentAPI.CompletionResponse{{Content: "已收到内核事件"}}}
a := newPreemptAgent(t, sp)
// Event 为 nilhandleInput 解引用即 panic。
bad := newInputTask(nil)
if !a.sched.enqueue(bad) {
t.Fatal("入队失败")
}
task, _, _ := a.sched.nextRef()
a.executeTask(task) // panic 被隔离
snap := a.DumpScheduler()
if snap.Immediate == nil {
t.Fatal("panic 必须产生一条内核 L4 中断")
}
if snap.Immediate.Level != LevelCritical {
t.Fatalf("panic 中断级别=%v期望 L4", snap.Immediate.Level)
}
text, _ := snap.Immediate.Event.Payload["content"].(string)
if !strings.Contains(text, "panic") {
t.Fatalf("panic 中断应说明发生了什么,实际 %q", text)
}
if snap.Immediate.Event.Payload["kernel"] != true {
t.Fatal("内核中断必须带 kernel 标记,便于与插件中断区分")
}
}
// 递归保护是结构性的L4 内核中断自己 panic 时,不再产生新的 L4。
func TestKernel_PanicInsideL4DoesNotRecurse(t *testing.T) {
sp := &scriptProvider{}
a := newPreemptAgent(t, sp)
evt, _ := textEvent("kernel", "内核事件")
l4 := newKernelInterruptTask(evt)
a.sched.immediate = l4
task, _, _ := a.sched.nextRef()
if task != l4 {
t.Fatal("应取到 L4 内核中断")
}
a.executeTask(&Task{ID: task.ID, Class: TaskInterrupt, Level: LevelCritical, Kind: TaskKindInput, Event: nil})
snap := a.DumpScheduler()
if snap.Immediate != nil || len(snap.PendingInterrupts) != 0 {
t.Fatalf("L4 自身 panic 不得再产生中断(否则自我放大),实际 immediate=%+v pending=%d",
snap.Immediate, len(snap.PendingInterrupts))
}
}
// 中断栈的 4 帧上界是**结构推论**:排队(L0) ← I(L1) ← I(L2) ← I(L3) ← I(L4 运行中)。
func TestKernel_StackBoundIsFullChain(t *testing.T) {
s := newScheduler(16)
frame := func() *TaskFrame { return &TaskFrame{} }
chain := []struct {
class TaskClass
lv Level
}{
{TaskQueued, 0},
{TaskInterrupt, LevelBackground},
{TaskInterrupt, LevelMessage},
{TaskInterrupt, LevelInteractive},
}
for i := 0; i < len(chain)-1; i++ {
s.suspend(&Task{ID: uint64(i + 1), Class: chain[i].class, Level: chain[i].lv}, frame())
}
if !s.canSuspend() {
t.Fatal("3 帧挂起时仍应容得下 L3第 4 级)继续下潜")
}
s.suspend(&Task{ID: 4, Class: TaskInterrupt, Level: LevelInteractive}, frame())
if s.canSuspend() {
t.Fatal("4 帧挂起 = 全链挂起L4 运行中),不应再有下潜余量")
}
}
// 端到端:插件声明 Priority → io.applyInjectOpts → payload → interruptLevel → 任务级别。
// 这条链路断在任何一环,插件声明的级别都会静默失效(降级到 L1
func TestKernel_PriorityFlowsThroughIOLayer(t *testing.T) {
ioM := agentIO.NewIOManager()
ioM.InjectInterruptTextOpts("qq", "cli", "通知", agentIO.InjectOptions{Priority: "L3"})
select {
case evt := <-ioM.InputInterruptChan():
if got := interruptLevel(evt); got != LevelInteractive {
t.Fatalf("经 io 层后的级别=%v期望 L3payload=%v", got, evt.Payload)
}
task := newInterruptTask(evt, interruptLevel(evt))
if task.Class != TaskInterrupt || task.Level != LevelInteractive {
t.Fatalf("中断任务类别/级别=%v/%v期望 interrupt/L3", task.Class, task.Level)
}
case <-time.After(2 * time.Second):
t.Fatal("中断未到达 interruptCh")
}
// 排队路径带 priority 也必须无效:排队输入没有级别。
ioM.InjectTextOpts("qq", "cli", "普通输入", agentIO.InjectOptions{Priority: "L3"})
select {
case evt := <-ioM.InputChan():
task := newInputTask(evt)
if task.Class != TaskQueued || task.Level != 0 {
t.Fatalf("排队任务类别/级别=%v/%v期望 queued/无级别", task.Class, task.Level)
}
if effectiveLevel(task) != 0 {
t.Fatalf("排队任务有效级=%v期望 0", effectiveLevel(task))
}
case <-time.After(2 * time.Second):
t.Fatal("排队输入未到达 inputCh")
}
}