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 轮转)通过。
This commit is contained in:
JianFeeeee
2026-09-13 06:57:23 +08:00
parent 86a702b3b0
commit cb32032f76
16 changed files with 1021 additions and 443 deletions

View File

@ -75,23 +75,44 @@ func newPreemptAgent(t *testing.T, sp agentAPI.Provider) *Agent {
})
}
func enqueueTask(t *testing.T, a *Agent, level Level, source, content string) (*Task, *agentIO.InputEvent) {
// enqueueQueued 入队一个**排队任务**(无级别)——对应 InjectText*/InjectInputSync*。
func enqueueQueued(t *testing.T, a *Agent, source, content string) (*Task, *agentIO.InputEvent) {
t.Helper()
evt, _ := textEvent(source, content)
task := &Task{Kind: TaskKindInput, Level: level, Event: evt, EnqueuedAt: time.Now()}
task := newInputTask(evt)
if !a.sched.enqueue(task) {
t.Fatal("入队失败")
}
return task, evt
}
// enqueueInterrupt 登记一次**中断**(走 requestPreempt:能抢占则进 immediate 槽,
// 否则按级别进中断队列),返回被登记的任务。
func enqueueInterrupt(t *testing.T, a *Agent, level Level, source, content string) (*Task, *agentIO.InputEvent) {
t.Helper()
evt, _ := textEvent(source, content)
evt.Payload["interrupt"] = true
a.sched.requestPreempt(evt, level)
snap := a.DumpScheduler()
if snap.Immediate != nil && snap.Immediate.Event == evt {
return snap.Immediate, evt
}
q := snap.InterruptQueues[clampPluginLevel(level)]
for i := len(q) - 1; i >= 0; i-- {
if q[i].Event == evt {
return q[i], evt
}
}
return nil, evt
}
// P1 + R1 + R5 + D1=A:高优先级抢占 → 挂起在 S_LLM → 中断任务带只读前缀 →
// 恢复后从 S_LLM 重发,且原任务的 msgs 未被改动。
func TestPreempt_HigherPreemptsAndResumes(t *testing.T) {
sp := newPreemptProvider("intr-done", "low-done")
a := newPreemptAgent(t, sp)
lowTask, _ := enqueueTask(t, a, LevelBackground, "qq", "低优先级任务")
lowTask, _ := enqueueQueued(t, a, "qq", "低优先级任务")
lt, _, kind := a.sched.nextRef()
if kind != nextReady || lt != lowTask {
t.Fatalf("应取到低优先级任务,kind=%v", kind)
@ -109,7 +130,7 @@ func TestPreempt_HigherPreemptsAndResumes(t *testing.T) {
// 注入 L4 中断(cli)
intrEvt, _ := textEvent("cli", "紧急打断")
intrEvt.Payload["interrupt"] = true
if !a.sched.requestPreempt(intrEvt, LevelCritical) {
if !a.sched.requestKernelPreempt(intrEvt) {
t.Fatal("L4 应请求抢占并返回 true(应取消 LLM)")
}
a.cancelCurrentLLM()
@ -146,7 +167,7 @@ func TestPreempt_HigherPreemptsAndResumes(t *testing.T) {
// Q3:三集合统一比较 → 下一轮取中断(L4 > L1)。
it, _, k := a.sched.nextRef()
if k != nextPending || it.Level != LevelCritical {
if k != nextImmediate || it.Level != LevelCritical {
t.Fatalf("应取到 pending 中断,kind=%v level=%v", k, it.Level)
}
// D1=B:中断任务在**上一个任务之前的完整状态**上开始运行,不继承本任务的现场。
@ -194,14 +215,17 @@ func TestPreempt_HigherPreemptsAndResumes(t *testing.T) {
}
}
// P2/P3:同级与更低级都不得抢占,请求进 pendingInterrupts。
// P2/P3:同级与更低级都不得抢占,请求进中断队列。
//
// 注意:能比“同级/更低不得抢占”的只可能是**中断之间**——排队任务无级别,
// 任何中断都能打断它(这是模型的规定,不是漏洞)。
func TestPreempt_LowerOrEqualDoesNotPreempt(t *testing.T) {
sp := newPreemptProvider("low-done", "intr-done")
a := newPreemptAgent(t, sp)
lowTask, _ := enqueueTask(t, a, LevelInteractive, "cli", "运行中的 L3")
if _, _, kind := a.sched.nextRef(); kind != nextReady {
t.Fatal("应取到运行任务")
lowTask, _ := enqueueInterrupt(t, a, LevelInteractive, "cli", "运行中的 L3")
if _, _, kind := a.sched.nextRef(); kind != nextInterrupt {
t.Fatal("应取到运行中的 L3 中断")
}
done := make(chan struct{})
@ -234,7 +258,7 @@ func TestPreempt_LowerOrEqualDoesNotPreempt(t *testing.T) {
t.Fatal("运行任务未结束")
}
if len(a.DumpScheduler().PendingInterrupts) != 2 {
t.Fatalf("两条未抢占中断都应保留在 pendingInterrupts,实际 %d",
t.Fatalf("两条未抢占中断都应保留在中断队列,实际 %d",
len(a.DumpScheduler().PendingInterrupts))
}
}
@ -243,23 +267,23 @@ func TestPreempt_LowerOrEqualDoesNotPreempt(t *testing.T) {
func TestPreempt_DepthCapBlocksSuspension(t *testing.T) {
a := newPreemptAgent(t, newPreemptProvider())
if a.sched.maxSuspendDepth != 4 {
t.Fatalf("默认深度上限=%d,期望 4", a.sched.maxSuspendDepth)
if a.sched.maxInterruptFrames != 4 {
t.Fatalf("默认栈深上界=%d,期望 4(= 中断级数,结构推论)", a.sched.maxInterruptFrames)
}
frame := func() *TaskFrame { return a.newTaskFrame("x", a.stageCtxFromInput("x", "", "")) }
for i := 0; i < a.sched.maxSuspendDepth; i++ {
a.sched.suspend(&Task{ID: uint64(i + 1), Level: LevelBackground}, frame())
for i := 0; i < a.sched.maxInterruptFrames; i++ {
a.sched.suspend(&Task{ID: uint64(i + 1), Class: TaskInterrupt, Level: LevelBackground}, frame())
}
if a.sched.canSuspend() {
t.Fatal("深度已达上限,canSuspend 应为 false")
}
// 超限兜底:仍保留帧(不丢副作用记录),但计数 Rejected。
before := a.DumpScheduler().Stats.Rejected
a.sched.suspend(&Task{ID: 99, Level: LevelBackground}, frame())
a.sched.suspend(&Task{ID: 99, Class: TaskInterrupt, Level: LevelBackground}, frame())
if a.DumpScheduler().Stats.Rejected != before+1 {
t.Fatal("超限挂起必须计数 Rejected")
}
if len(a.DumpScheduler().SuspendStack) != a.sched.maxSuspendDepth+1 {
if len(a.DumpScheduler().SuspendStack) != a.sched.maxInterruptFrames+1 {
t.Fatal("兜底路径必须保留帧而不是丢弃")
}
}
@ -273,10 +297,10 @@ func TestPreempt_IdleInterruptIsQueuedNotLost(t *testing.T) {
t.Fatal("空闲时不应请求取消 LLM(没有运行任务)")
}
if len(a.DumpScheduler().PendingInterrupts) != 1 {
t.Fatal("空闲时的中断必须进 pendingInterrupts")
t.Fatal("空闲时的中断必须进中断队列(不能丢)")
}
task, _, kind := a.sched.nextRef()
if kind != nextPending || task.Level != LevelMessage {
if kind != nextInterrupt || task.Level != LevelMessage {
t.Fatalf("应取到待处理中断,kind=%v", kind)
}
a.executeNewTask(task)