mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-21 17:38:10 +00:00
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:
@ -50,10 +50,13 @@ func (a *Agent) interceptLoop() {
|
||||
clone.Payload["interrupt_source"] = evt.Source
|
||||
clone.Payload["interrupt_channel"] = evt.OutputChannel
|
||||
|
||||
// 决策交给调度器:requestPreempt 总是登记中断(进 pendingInterrupts,
|
||||
// 决策交给调度器:requestPreempt 总会登记中断(进中断队列或 immediate,
|
||||
// 因而不会丢),仅当它会真抢占时才告诉我“该取消可取消的步骤”。
|
||||
// 本 goroutine 不碰任何帧——只写 pendingInterrupts 与让位信号。
|
||||
level := a.taskLevel(evt.Source, evt.OutputChannel)
|
||||
// 本 goroutine 不碰任何帧——只写中断队列与让位信号。
|
||||
//
|
||||
// 级别由插件声明(InjectOptions.Priority → payload["priority"],L1..L3);
|
||||
// 未声明一律 L1。L4 只能由内核的 raiseKernelInterrupt 产生。
|
||||
level := interruptLevel(evt)
|
||||
if a.sched.requestPreempt(clone, level) {
|
||||
a.cancelCurrentLLM()
|
||||
}
|
||||
|
||||
@ -1,23 +1,36 @@
|
||||
package core
|
||||
|
||||
// 输入调度器(M2:骨架)。
|
||||
// 输入调度器:四级中断优先级 · 可抢占 · 现场保存/恢复。
|
||||
//
|
||||
// 设计依据 docs/zh/input-scheduler-design.md。
|
||||
//
|
||||
// M2 只建立结构,不引入抢占:
|
||||
// - 显式的 readyQueue 与 Task 抽象(取代 eventLoop 里隐式的 channel 排队);
|
||||
// - 统一的排序键 (-Level, EnqueuedAt, ID)(设计文档 §4.1);
|
||||
// - 原子快照 DumpScheduler() 与计数(可观测性);
|
||||
// - **每任务 panic 隔离**:panic 只使该任务失败,调度器本身存活(不变量 I6)。
|
||||
// # 模型(两类别 + 四级)
|
||||
//
|
||||
// M2 全部任务都是 LevelBackground(默认级),因此排序结果等价于 FIFO——
|
||||
// 与改造前的 channel 语义逐条一致。抢占、中断栈、pendingInterrupts、
|
||||
// 任务级回执在 M3–M6 加入。
|
||||
// 类别由**用哪个注入 API**决定,与通道名无关:
|
||||
// - TaskInterrupt:InjectInterrupt* 注入。带级别 L1..L4,可抢占,
|
||||
// 可被更高级中断打断(被打断的现场压入**中断栈**)。
|
||||
// - TaskQueued:InjectText*/InjectInputSync* 与内核自循环。**无级别**,
|
||||
// 用于“不需及时处理”的场景,可被**任何**中断打断。
|
||||
//
|
||||
// 并发模型(不变量 I2):readyQueue/running/stats 只由 schedulerLoop 写,
|
||||
// 外部只读——读取一律经 DumpScheduler() 加锁取快照。
|
||||
// 级别只属于中断:
|
||||
// - L1..L3 由插件在 InjectOptions.Priority 里声明(见 clampPluginLevel);
|
||||
// - L4 由内核独占,只能经 raiseKernelInterrupt 产生(panic / selfip)。
|
||||
//
|
||||
// # 选择顺序
|
||||
//
|
||||
// 1. immediate —— 刚抢占成功的中断(抢占必须立即生效)
|
||||
// 2. 中断队列 L4→L1(同级 FIFO)
|
||||
// 3. 中断栈顶(与 2 的队头比级别,取高者;栈顶无级别时中断必胜)
|
||||
// 4. 排队队列(FIFO)
|
||||
//
|
||||
// # 并发模型(不变量 I2)
|
||||
//
|
||||
// queue/running/栈/stats 只由 schedulerLoop 与调度 goroutine 写;
|
||||
// interruptLoop 只写中断登记与让位信号,**从不碰帧**。外部读取一律经
|
||||
// DumpScheduler() 加锁取快照。
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"runtime/debug"
|
||||
"sync"
|
||||
@ -29,27 +42,45 @@ import (
|
||||
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||
)
|
||||
|
||||
// Level 是任务优先级,由内核预定义四级(设计文档 §3.1)。
|
||||
// Level 是**中断**的优先级,由内核预定义四级。
|
||||
//
|
||||
// 取值域刻意只有四档:不引入任意整数,避免"9 级比 4 级大但没人知道怎么排"。
|
||||
// 语义:它衡量“这项工作有多不能等”,与具体通道名无关。
|
||||
// 插件在中断注入时通过 InjectOptions.Priority 声明 L1..L3;
|
||||
// **L4 由内核独占**(panic、内核事件 selfip),插件声明 L4 会被夹到 L3。
|
||||
//
|
||||
// 排队输入(InjectText* / InjectInputSync*)**没有级别**:它们本就是
|
||||
// “不需及时处理”的那一类,可被任何中断打断(见 TaskClass)。
|
||||
type Level int
|
||||
|
||||
const (
|
||||
// LevelBackground 后台维护:心跳蒸馏/归档/合并/复审、子任务、consolidation。
|
||||
// LevelBackground L1:完全可等。例:QQ/微信这类异步消息、批量通知。
|
||||
LevelBackground Level = 1
|
||||
// LevelMessage 异步消息:QQ/微信等入站消息、插件通知。
|
||||
// LevelMessage L2:一般提醒。例:插件希望尽快看到、但不紧急的提示。
|
||||
LevelMessage Level = 2
|
||||
// LevelInteractive 人机交互:用户在 CLI/WebUI 的直接对话。
|
||||
// LevelInteractive L3:需及时处理。例:时钟/定时器到达、终端输出、交互输入。
|
||||
LevelInteractive Level = 3
|
||||
// LevelCritical 紧急打断:显式打断、系统告警、安全类中断。
|
||||
// LevelCritical L4:**内核独占**。panic 中断、内核事件中断(selfip)。
|
||||
// 插件不得声明此级。
|
||||
LevelCritical Level = 4
|
||||
)
|
||||
|
||||
// DefaultLevel 是未显式声明时的优先级。
|
||||
// DefaultLevel 是未显式声明时的中断级别。
|
||||
//
|
||||
// 取最低级是刻意的:**显式才是特权**,新插件不会默认拿到抢占权。
|
||||
const DefaultLevel = LevelBackground
|
||||
|
||||
// clampPluginLevel 把插件声明的级别夹到允许范围(L1..L3)。
|
||||
// L4 是内核的调度内部属性,不接受外部越权。
|
||||
func clampPluginLevel(l Level) Level {
|
||||
if l < LevelBackground {
|
||||
return DefaultLevel
|
||||
}
|
||||
if l > LevelInteractive {
|
||||
return LevelInteractive
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
func (l Level) String() string {
|
||||
switch l {
|
||||
case LevelBackground:
|
||||
@ -67,10 +98,37 @@ func (l Level) String() string {
|
||||
|
||||
// ParseLevel 已删除。
|
||||
//
|
||||
// 为何不保留:优先级是**内核内部属性**,不是配置项——内核预定义四级
|
||||
// (L1 后台 / L2 消息 / L3 交互 / L4 紧急),由内核按内部规则为任务与中断定级。
|
||||
// 曾一度做成 `core.agent.priority.<channel>` 这种“策略表 + 字符串解析”,
|
||||
// 那等于把内核的内部属性外化成运维配置,与设计意图相反。
|
||||
// 为何不保留:优先级是**内核内部属性**,不是配置项。
|
||||
// 曾一度做成 `core.agent.priority.<channel>`(配置中心可见),
|
||||
// 那等于把内核的调度内部属性外化成运维配置,与设计意图相反。
|
||||
//
|
||||
// 现在级别的来源只有两个(见 Task/Level 注释):
|
||||
// - 插件在中断注入时声明(InjectOptions.Priority,L1..L3);
|
||||
// - 内核内部产生 L4(panic / selfip)。
|
||||
|
||||
// TaskClass 是任务的两大类别——**由“用哪个注入 API”决定,与通道名无关**。
|
||||
//
|
||||
// 这是模型的核心区分:
|
||||
// - InjectInterrupt* → TaskInterrupt:带级别,可抢占,可被更高级中断打断(→ 中断栈)
|
||||
// - InjectText* / InjectInputSync* / 内核自循环 → TaskQueued:无级别,
|
||||
// 可被**任何**中断打断(“用于不需要及时处理的场景”)
|
||||
type TaskClass int
|
||||
|
||||
const (
|
||||
TaskQueued TaskClass = iota
|
||||
TaskInterrupt
|
||||
)
|
||||
|
||||
func (c TaskClass) String() string {
|
||||
switch c {
|
||||
case TaskQueued:
|
||||
return "queued"
|
||||
case TaskInterrupt:
|
||||
return "interrupt"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// TaskKind 区分任务来源。
|
||||
type TaskKind int
|
||||
@ -94,11 +152,10 @@ func (k TaskKind) String() string {
|
||||
}
|
||||
|
||||
// Task 是调度器的最小单位。
|
||||
//
|
||||
// M2 只承载"一份待处理的输入";M3 起把 TaskFrame(现场)挂上来,
|
||||
// 使其成为可挂起/可恢复的执行单元。
|
||||
type Task struct {
|
||||
ID uint64
|
||||
ID uint64
|
||||
Class TaskClass
|
||||
// Level 仅对 TaskInterrupt 有意义;TaskQueued 恒为 0(无级别)。
|
||||
Level Level
|
||||
Kind TaskKind
|
||||
EnqueuedAt time.Time
|
||||
@ -120,11 +177,17 @@ const preemptPromotionCap = 2
|
||||
// 避免高优先级流把同一任务反复打断到永不完结。
|
||||
const preemptCooldown = 2 * time.Second
|
||||
|
||||
// effectiveLevel 返回任务的**有效**优先级(设计文档 §9 饥饿防护)。
|
||||
// effectiveLevel 返回任务的**有效**级别。
|
||||
//
|
||||
// 被抢占越多的任务越“值钱”,从而逐步追上抢占它的流;封顶 L4,
|
||||
// 因此它永远不会反过来抢占真正的紧急输入。
|
||||
// 排队输入恒为 0(无级别):任何中断(≥ L1)都大于它——这正好实现
|
||||
// “排队输入可被任何中断打断”。
|
||||
//
|
||||
// 中断则叠加饥饿防护:被抢占越多的中断越“值钱”,逐步追上抢占它的流;
|
||||
// 封顶 L4,因此它永远不会反过来抢占内核紧急中断。
|
||||
func effectiveLevel(t *Task) Level {
|
||||
if t.Class != TaskInterrupt {
|
||||
return 0
|
||||
}
|
||||
p := t.PreemptCount
|
||||
if p > preemptPromotionCap {
|
||||
p = preemptPromotionCap
|
||||
@ -136,6 +199,21 @@ func effectiveLevel(t *Task) Level {
|
||||
return l
|
||||
}
|
||||
|
||||
// canPreempt 是唯一的抢占判据。
|
||||
//
|
||||
// 由于 effectiveLevel(排队)=0,这一个比较同时覆盖两条规则:
|
||||
// - running 是排队任务 → 任何中断(≥L1)都能抢占;
|
||||
// - running 是中断 Li → 只有 Lj > Li 的中断能抢占(严格大于)。
|
||||
func canPreempt(incoming, running *Task) bool {
|
||||
if incoming == nil || running == nil {
|
||||
return false
|
||||
}
|
||||
if incoming.Class != TaskInterrupt {
|
||||
return false // 排队输入从不抢占
|
||||
}
|
||||
return effectiveLevel(incoming) > effectiveLevel(running)
|
||||
}
|
||||
|
||||
// SchedulerStats 是调度器的累计计数(可观测性,设计文档 §11 O2)。
|
||||
type SchedulerStats struct {
|
||||
Enqueued uint64
|
||||
@ -149,13 +227,20 @@ type SchedulerStats struct {
|
||||
|
||||
// SchedulerSnapshot 是调度器的原子快照。
|
||||
type SchedulerSnapshot struct {
|
||||
Running *Task
|
||||
Queue []*Task
|
||||
Running *Task
|
||||
// Queue 是排队输入队列(无级别,FIFO)。
|
||||
Queue []*Task
|
||||
// InterruptQueues[level] 是四条中断队列(下标 1..4,同级 FIFO)。
|
||||
InterruptQueues [5][]*Task
|
||||
// Immediate 是刚抢占成功、将在下一个安全点立即运行的中断(最多一个)。
|
||||
Immediate *Task
|
||||
// PendingInterrupts = 四条中断队列 + Immediate(对外的待处理中断总数视图)。
|
||||
PendingInterrupts []*Task
|
||||
// SuspendStack:中断栈(含嵌套抢占的多个现场),**栈顶**优先恢复。
|
||||
SuspendStack []*suspendedTask
|
||||
Stats SchedulerStats
|
||||
MaxSuspendDepth int
|
||||
SuspendStack []*suspendedTask
|
||||
Stats SchedulerStats
|
||||
// MaxInterruptFrames 是中断栈帧数的结构上界(= 中断级数,不是配置项)。
|
||||
MaxInterruptFrames int
|
||||
}
|
||||
|
||||
// schedulerStatus 把快照转成对外的状态 DTO(不暴露帧内容)。
|
||||
@ -168,7 +253,7 @@ func (a *Agent) schedulerStatus() sdk.SchedulerStatus {
|
||||
ReadyQueueDepth: len(snap.Queue),
|
||||
PendingInterrupts: len(snap.PendingInterrupts),
|
||||
SuspendStack: len(snap.SuspendStack),
|
||||
MaxSuspendDepth: snap.MaxSuspendDepth,
|
||||
MaxSuspendDepth: snap.MaxInterruptFrames,
|
||||
Enqueued: snap.Stats.Enqueued,
|
||||
Executed: snap.Stats.Executed,
|
||||
Rejected: snap.Stats.Rejected,
|
||||
@ -192,9 +277,14 @@ type scheduler struct {
|
||||
stats SchedulerStats
|
||||
maxQueue int
|
||||
|
||||
// pendingInterrupts:因优先级不足(或运行任务在临界区)而未立即抢占的中断请求。
|
||||
// 与 readyQueue 分离:取出时以中断语义启动(设计文档 D3)。
|
||||
pendingInterrupts []*Task
|
||||
// interruptQueues[level]:四条**中断队列**(level 1..4),同级 FIFO。
|
||||
// 未能立即抢占的中断(级别不足,或运行任务在临界区)按级别入队,
|
||||
// nextRef 从 L4 到 L1 依次扫描。
|
||||
interruptQueues [5][]*Task
|
||||
// immediate:刚抢占成功的中断。抢占必须**立即生效**,所以它不经队列,
|
||||
// 在下一个安全点直接运行。这也消除了“抢占者与被抢占者同级”的比较问题——
|
||||
// 抢占者根本不需要和栈顶比。
|
||||
immediate *Task
|
||||
// suspendStack:**中断栈**。被抢占后保存现场的任务压栈(LIFO),
|
||||
// 用于“中断被中断”的嵌套场景:只有**栈顶**参与恢复选择,栈内不做优先级重排。
|
||||
suspendStack []*suspendedTask
|
||||
@ -208,8 +298,12 @@ type scheduler struct {
|
||||
// wake 用于把空闲的调度器叫醒:pendingInterrupts 不是 channel,
|
||||
// 没有这个信号时“空闲时到达的中断”会一直等下一次输入(设计 §5.1 ③)。
|
||||
wake chan struct{}
|
||||
// maxSuspendDepth:中断栈深度上限(设计文档 §6.3,默认 4)。
|
||||
maxSuspendDepth int
|
||||
// maxInterruptFrames:中断栈帧数的**结构上界**,不是配置项。
|
||||
//
|
||||
// 链条 = 排队(L0) ← I(L1) ← I(L2) ← I(L3) ← I(L4 运行中),
|
||||
// 被挂起 4 帧;L4 之上没有更高级别,链到此为止。超限只可能是内核 bug,
|
||||
// 因此这里只做防御性计数,**不降级、不丢弃帧**。
|
||||
maxInterruptFrames int
|
||||
}
|
||||
|
||||
// suspendedTask 是一个被抢占任务的现场。
|
||||
@ -224,7 +318,8 @@ type nextSelection int
|
||||
const (
|
||||
nextNone nextSelection = iota
|
||||
nextReady
|
||||
nextPending
|
||||
nextInterrupt
|
||||
nextImmediate
|
||||
nextSuspended
|
||||
)
|
||||
|
||||
@ -232,7 +327,11 @@ func newScheduler(maxQueue int) *scheduler {
|
||||
if maxQueue <= 0 {
|
||||
maxQueue = 256
|
||||
}
|
||||
return &scheduler{maxQueue: maxQueue, maxSuspendDepth: 4, wake: make(chan struct{}, 1)}
|
||||
return &scheduler{
|
||||
maxQueue: maxQueue,
|
||||
maxInterruptFrames: int(LevelCritical), // 结构推论:= 中断级数
|
||||
wake: make(chan struct{}, 1),
|
||||
}
|
||||
}
|
||||
|
||||
// signalWake 非阻塞地唤醒调度器。
|
||||
@ -249,7 +348,7 @@ func (s *scheduler) setCritical(v bool) { s.critical.Store(v) }
|
||||
// inCritical 报告运行任务是否在不可抢占临界区。
|
||||
func (s *scheduler) inCritical() bool { return s.critical.Load() }
|
||||
|
||||
// hasRoom 报告就绪队列是否还能接收任务。泵入侧据此节流:
|
||||
// hasRoom 报告排队队列是否还能接收任务。泵入侧据此节流:
|
||||
// 队列满则停止从 channel 取,让背压落回 channel 本身。
|
||||
func (s *scheduler) hasRoom() bool {
|
||||
s.mu.Lock()
|
||||
@ -257,7 +356,16 @@ func (s *scheduler) hasRoom() bool {
|
||||
return len(s.queue) < s.maxQueue
|
||||
}
|
||||
|
||||
// enqueue 入队;队列满返回 false(调用方负责计数)。
|
||||
// allocateIDLocked 分配任务 ID 与入队时刻(调用方持锁)。
|
||||
func (s *scheduler) allocateIDLocked(t *Task) {
|
||||
s.seq++
|
||||
t.ID = s.seq
|
||||
if t.EnqueuedAt.IsZero() {
|
||||
t.EnqueuedAt = time.Now()
|
||||
}
|
||||
}
|
||||
|
||||
// enqueue 把一个**排队输入**入队;队列满返回 false(调用方负责计数)。
|
||||
func (s *scheduler) enqueue(t *Task) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
@ -265,11 +373,7 @@ func (s *scheduler) enqueue(t *Task) bool {
|
||||
s.stats.Rejected++
|
||||
return false
|
||||
}
|
||||
s.seq++
|
||||
t.ID = s.seq
|
||||
if t.EnqueuedAt.IsZero() {
|
||||
t.EnqueuedAt = time.Now()
|
||||
}
|
||||
s.allocateIDLocked(t)
|
||||
s.stats.Enqueued++
|
||||
s.queue = append(s.queue, t)
|
||||
return true
|
||||
@ -283,74 +387,94 @@ func (s *scheduler) next() *Task {
|
||||
return t
|
||||
}
|
||||
|
||||
// nextRef 从三个集合中按统一排序键取出下一个任务。
|
||||
// nextRef 选出下一个任务。优先顺序:
|
||||
//
|
||||
// 设计文档 §4.1:高有效级先;同级先到先服务。挂起任务保留其**原始**入队时刻,
|
||||
// 因此同级时天然倾向“先把旧任务做完”,抑制饥饿。
|
||||
// 1. immediate —— 刚抢占成功的中断(抢占必须立即生效)
|
||||
// 2. 中断队列 L4→L1(同级 FIFO)
|
||||
// 3. 中断栈顶(与 2 比级别取高者;栈顶是排队任务时视为最低)
|
||||
// 4. 排队队列(FIFO)
|
||||
func (s *scheduler) nextRef() (*Task, *TaskFrame, nextSelection) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
var bestTask *Task
|
||||
var bestFrame *TaskFrame
|
||||
bestKind := nextNone
|
||||
consider := func(t *Task, k nextSelection, fr *TaskFrame) {
|
||||
if bestTask == nil {
|
||||
bestTask, bestKind, bestFrame = t, k, fr
|
||||
return
|
||||
}
|
||||
lt, lb := effectiveLevel(t), effectiveLevel(bestTask)
|
||||
if lt != lb {
|
||||
if lt > lb {
|
||||
bestTask, bestKind, bestFrame = t, k, fr
|
||||
}
|
||||
return
|
||||
}
|
||||
// 同级时 **pending 中断优先**。
|
||||
//
|
||||
// 为何必需:一次抢占生效后,被挂起的原任务会因饥饿防护提升有效级,
|
||||
// 于是与抢占者同级;若此时按“先到先服务”,原任务(入队更早)会被
|
||||
// 立刻选回,抢占者永远排不到——抢占变成空转。
|
||||
if k == nextPending && bestKind != nextPending {
|
||||
bestTask, bestKind, bestFrame = t, k, fr
|
||||
return
|
||||
}
|
||||
if bestKind == nextPending && k != nextPending {
|
||||
return
|
||||
}
|
||||
// 同级同类:先到先服务(ID 兜底保证确定性)。
|
||||
if t.EnqueuedAt.Before(bestTask.EnqueuedAt) ||
|
||||
(t.EnqueuedAt.Equal(bestTask.EnqueuedAt) && t.ID < bestTask.ID) {
|
||||
bestTask, bestKind, bestFrame = t, k, fr
|
||||
}
|
||||
}
|
||||
for _, t := range s.queue {
|
||||
consider(t, nextReady, nil)
|
||||
}
|
||||
for _, t := range s.pendingInterrupts {
|
||||
consider(t, nextPending, nil)
|
||||
}
|
||||
// 中断栈:只比**栈顶**(严格 LIFO)。栈内不做优先级重排——
|
||||
// 嵌套抢占天然使栈自底向上优先级递增,且“后被打断的先恢复”才是栈语义。
|
||||
if n := len(s.suspendStack); n > 0 {
|
||||
top := s.suspendStack[n-1]
|
||||
consider(top.Task, nextSuspended, top.Frame)
|
||||
}
|
||||
if bestTask == nil {
|
||||
return nil, nil, nextNone
|
||||
if s.immediate != nil {
|
||||
t := s.immediate
|
||||
s.immediate = nil
|
||||
s.running = t
|
||||
return t, nil, nextImmediate
|
||||
}
|
||||
|
||||
switch bestKind {
|
||||
case nextReady:
|
||||
s.queue = removeTask(s.queue, bestTask)
|
||||
case nextPending:
|
||||
s.pendingInterrupts = removeTask(s.pendingInterrupts, bestTask)
|
||||
case nextSuspended:
|
||||
// 只有栈顶可能被选中,故弹出即截断末位。
|
||||
s.suspendStack = s.suspendStack[:len(s.suspendStack)-1]
|
||||
qTask, qLevel := s.highestInterruptLocked()
|
||||
|
||||
// 中断栈:只比**栈顶**(严格 LIFO)。栈内不做优先级重排——
|
||||
// 嵌套抢占天然使栈自底向上级别递增,且“后被打断的先恢复”才是栈语义。
|
||||
if n := len(s.suspendStack); n > 0 {
|
||||
top := s.suspendStack[n-1]
|
||||
// 栈顶 vs 最高级待处理中断:取高者(持平归栈顶,维持 LIFO 与公平)。
|
||||
if qTask == nil || effectiveLevel(top.Task) >= qLevel {
|
||||
s.suspendStack = s.suspendStack[:n-1]
|
||||
s.stats.Resumed++
|
||||
s.running = top.Task
|
||||
return top.Task, top.Frame, nextSuspended
|
||||
}
|
||||
}
|
||||
s.running = bestTask
|
||||
return bestTask, bestFrame, bestKind
|
||||
|
||||
if qTask != nil {
|
||||
s.popInterruptLocked(qLevel)
|
||||
s.running = qTask
|
||||
return qTask, nil, nextInterrupt
|
||||
}
|
||||
|
||||
if len(s.queue) > 0 {
|
||||
t := s.queue[0]
|
||||
s.queue = s.queue[1:]
|
||||
s.running = t
|
||||
return t, nil, nextReady
|
||||
}
|
||||
return nil, nil, nextNone
|
||||
}
|
||||
|
||||
// highestInterruptLocked 返回当前最高级非空中断队列的队头及其级别。
|
||||
func (s *scheduler) highestInterruptLocked() (*Task, Level) {
|
||||
for lv := LevelCritical; lv >= LevelBackground; lv-- {
|
||||
if q := s.interruptQueues[lv]; len(q) > 0 {
|
||||
return q[0], lv
|
||||
}
|
||||
}
|
||||
return nil, 0
|
||||
}
|
||||
|
||||
// popInterruptLocked 弹出某级别中断队列的队头(调用方已确认非空)。
|
||||
func (s *scheduler) popInterruptLocked(lv Level) {
|
||||
s.interruptQueues[lv] = s.interruptQueues[lv][1:]
|
||||
}
|
||||
|
||||
// interruptCountLocked 统计所有待处理中断(含 immediate 槽)。
|
||||
func (s *scheduler) interruptCountLocked() int {
|
||||
n := 0
|
||||
for lv := LevelBackground; lv <= LevelCritical; lv++ {
|
||||
n += len(s.interruptQueues[lv])
|
||||
}
|
||||
if s.immediate != nil {
|
||||
n++
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// setImmediateLocked 登记一个应“立即运行”的抢占者。
|
||||
//
|
||||
// 槽只有一格:若已有抢占者且新的级别更高,旧的降级入队;否则新的入队。
|
||||
func (s *scheduler) setImmediateLocked(t *Task) {
|
||||
if s.immediate != nil && effectiveLevel(t) <= effectiveLevel(s.immediate) {
|
||||
s.enqueueInterruptLocked(t)
|
||||
return
|
||||
}
|
||||
if s.immediate != nil {
|
||||
s.enqueueInterruptLocked(s.immediate)
|
||||
}
|
||||
s.allocateIDLocked(t)
|
||||
s.stats.Enqueued++
|
||||
s.immediate = t
|
||||
}
|
||||
|
||||
func removeTask(list []*Task, target *Task) []*Task {
|
||||
@ -362,52 +486,75 @@ func removeTask(list []*Task, target *Task) []*Task {
|
||||
return list
|
||||
}
|
||||
|
||||
// enqueueInterrupt 把一个未立即抢占的中断请求放进 pendingInterrupts。
|
||||
// enqueueInterruptLocked 把一个未立即抢占的中断按其级别入队(调用方持锁)。
|
||||
//
|
||||
// 有界:满了丢**最老**的一条并计数(中断是提示性输入,宁可丢旧保新)。
|
||||
func (s *scheduler) enqueueInterrupt(t *Task) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.seq++
|
||||
t.ID = s.seq
|
||||
if t.EnqueuedAt.IsZero() {
|
||||
t.EnqueuedAt = time.Now()
|
||||
func (s *scheduler) enqueueInterruptLocked(t *Task) {
|
||||
s.allocateIDLocked(t)
|
||||
if s.interruptCountLocked() >= s.maxQueue {
|
||||
for lv := LevelBackground; lv <= LevelCritical; lv++ {
|
||||
if len(s.interruptQueues[lv]) > 0 {
|
||||
s.popInterruptLocked(lv)
|
||||
s.stats.Rejected++
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(s.pendingInterrupts) >= s.maxQueue {
|
||||
s.pendingInterrupts = s.pendingInterrupts[1:]
|
||||
s.stats.Rejected++
|
||||
lv := t.Level
|
||||
if lv < LevelBackground || lv > LevelCritical {
|
||||
lv = DefaultLevel
|
||||
}
|
||||
s.pendingInterrupts = append(s.pendingInterrupts, t)
|
||||
s.signalWake()
|
||||
s.interruptQueues[lv] = append(s.interruptQueues[lv], t)
|
||||
s.stats.Enqueued++
|
||||
}
|
||||
|
||||
// requestPreempt 登记一次中断请求。
|
||||
// requestPreempt 登记一次中断请求(class=TaskInterrupt)。
|
||||
//
|
||||
// 返回 true 表示“应该尝试取消运行任务正在进行的可取消步骤(LLM 流式)”。
|
||||
//
|
||||
// 无论能否抢占,中断请求都进 pendingInterrupts——这样即使运行任务在抢占生效前
|
||||
// 就正常结束,中断也不会丢(它会被 nextRef 按优先级选出)。
|
||||
// 判据是 canPreempt(由优先级级别系统一承担),并受抢占冷却约束:
|
||||
// - running 是排队任务 → 任何中断都抢占;
|
||||
// - running 是中断 Li → 仅 Lj > Li 的中断抢占。
|
||||
//
|
||||
// 判据用**有效**优先级(饥饿防护),并受抢占冷却约束。
|
||||
// 能抢占时把中断放进 immediate(立即生效);否则按其级别入队,等当前任务
|
||||
// 结束或下一个安全点再处理——无论哪种,中断都不会丢。
|
||||
//
|
||||
// 临界区(如记忆整理)内不 arm、不取消:中断只入队,等临界区结束后的安全点处理,
|
||||
// 这是设计 §4.3 的硬要求——那个位置的“不抢占”不能只是不让位,还必须不取消。
|
||||
func (s *scheduler) requestPreempt(evt *agentIO.InputEvent, level Level) bool {
|
||||
return s.registerInterrupt(newInterruptTask(evt, clampPluginLevel(level)))
|
||||
}
|
||||
|
||||
// requestKernelPreempt 是**内核**中断入口(panic / 内核事件 selfip)。
|
||||
//
|
||||
// 级别固定 L4,且**不夹取**——这是 L4 的唯一来源,插件永远够不到。
|
||||
func (s *scheduler) requestKernelPreempt(evt *agentIO.InputEvent) bool {
|
||||
return s.registerInterrupt(newKernelInterruptTask(evt))
|
||||
}
|
||||
|
||||
// registerInterrupt 是登记中断的公共实现(任务已带好 Class/Level)。
|
||||
func (s *scheduler) registerInterrupt(t *Task) bool {
|
||||
s.mu.Lock()
|
||||
running := s.running
|
||||
critical := s.critical.Load()
|
||||
canPreempt := false
|
||||
if !critical && running != nil && level > effectiveLevel(running) {
|
||||
arm := false
|
||||
if !critical && canPreempt(t, running) {
|
||||
if running.LastPreemptAt.IsZero() || time.Since(running.LastPreemptAt) >= preemptCooldown {
|
||||
canPreempt = true
|
||||
arm = true
|
||||
s.preemptArmed = true
|
||||
s.preemptLevel = level
|
||||
s.preemptLevel = t.Level
|
||||
s.setImmediateLocked(t)
|
||||
}
|
||||
}
|
||||
if !arm {
|
||||
s.enqueueInterruptLocked(t)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
s.enqueueInterrupt(newInterruptTask(evt, level))
|
||||
return canPreempt
|
||||
if !arm {
|
||||
s.signalWake()
|
||||
}
|
||||
return arm
|
||||
}
|
||||
|
||||
// preemptGrantedFor 报告运行任务是否应在当前安全点让位。
|
||||
@ -429,12 +576,12 @@ func (s *scheduler) clearPreempt() {
|
||||
|
||||
// suspend 保存现场。
|
||||
//
|
||||
// 深度上限(设计文档 §6.3):安全点上的 canSuspend 已提前拦下超限情况,
|
||||
// 此处仅在竞态下兜底计数——绝不丢弃帧(帧丢了会丢副作用记录)。
|
||||
// 深度上界是**结构推论**(= 中断级数),不是配置项:安全点上的 canSuspend 已提前
|
||||
// 拦下超限情况,此处仅在竞态下兜底计数——绝不丢弃帧(帧丢了会丢副作用记录)。
|
||||
func (s *scheduler) suspend(t *Task, f *TaskFrame) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if len(s.suspendStack) >= s.maxSuspendDepth {
|
||||
if len(s.suspendStack) >= s.maxInterruptFrames {
|
||||
s.stats.Rejected++
|
||||
}
|
||||
s.suspendStack = append(s.suspendStack, &suspendedTask{Task: t, Frame: f})
|
||||
@ -456,7 +603,7 @@ func (s *scheduler) suspend(t *Task, f *TaskFrame) {
|
||||
func (s *scheduler) canSuspend() bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return len(s.suspendStack) < s.maxSuspendDepth
|
||||
return len(s.suspendStack) < s.maxInterruptFrames
|
||||
}
|
||||
|
||||
// done 标记任务执行结束。
|
||||
@ -466,7 +613,7 @@ func (s *scheduler) done(t *Task) {
|
||||
if s.running == t {
|
||||
s.running = nil
|
||||
}
|
||||
// 任务正常结束:让位信号不再有意义(中断已在 pendingInterrupts 里)。
|
||||
// 任务正常结束:让位信号不再有意义(中断已在中断队列/immediate 里)。
|
||||
s.preemptArmed = false
|
||||
s.preemptLevel = 0
|
||||
s.stats.Executed++
|
||||
@ -475,37 +622,42 @@ func (s *scheduler) done(t *Task) {
|
||||
// currentLevel 返回当前正在执行任务的级别;无 running 时为默认级。
|
||||
//
|
||||
// 用于在 prepare 段把级别写进帧(抢占比较的基准)。
|
||||
func (s *scheduler) currentLevel() Level {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.running != nil {
|
||||
return s.running.Level
|
||||
// interruptLevel 返回一次**中断注入**的级别。
|
||||
//
|
||||
// 级别是“这项工作有多不能等”,由插件在 InjectOptions.Priority 里声明
|
||||
// (排队注入没有级别,它们的 TaskClass 是 TaskQueued)。
|
||||
//
|
||||
// 取值域 L1..L3;空/非法一律降到 DefaultLevel(L1)。
|
||||
// **L4 不在此处产生**:它由内核独占,经 raiseKernelInterrupt 直接给出
|
||||
// (panic / 内核事件 selfip),因此 clampPluginLevel 会把越权声明夹回 L3。
|
||||
func interruptLevel(evt *agentIO.InputEvent) Level {
|
||||
if evt == nil || evt.Payload == nil {
|
||||
return DefaultLevel
|
||||
}
|
||||
return DefaultLevel
|
||||
raw, _ := evt.Payload["priority"].(string)
|
||||
l, ok := parseInterruptLevel(raw)
|
||||
if !ok {
|
||||
return DefaultLevel
|
||||
}
|
||||
return clampPluginLevel(l)
|
||||
}
|
||||
|
||||
// taskLevel 是内核为任务定级的内部规则。
|
||||
//
|
||||
// ❗优先级是**内核内部属性**,不做成配置项:内核预定义四级,并按内部规则
|
||||
// 为任务与中断定级。下方规则只是 v1 的内部缺省值——它决定“谁能让位于谁”,
|
||||
// 属于内核自己的隐私,不对外暴露为运维可调项。
|
||||
//
|
||||
// 缺省:cli/webui/http 为人机交互(L3),system/_consolidation_ 为后台(L1),
|
||||
// 其余一律默认级(L1)。
|
||||
func (a *Agent) taskLevel(source, channel string) Level {
|
||||
switch channel {
|
||||
case "cli", "webui", "http":
|
||||
return LevelInteractive
|
||||
case channelConsolidation, "system":
|
||||
return LevelBackground
|
||||
// parseInterruptLevel 解析插件声明的级别字符串("L1".."L3")。
|
||||
// 只认字面量:拼写错误必须降级成默认级而不是被静默当成别的级别。
|
||||
func parseInterruptLevel(s string) (Level, bool) {
|
||||
switch s {
|
||||
case "L1", "l1":
|
||||
return LevelBackground, true
|
||||
case "L2", "l2":
|
||||
return LevelMessage, true
|
||||
case "L3", "l3":
|
||||
return LevelInteractive, true
|
||||
case "L4", "l4":
|
||||
// 内核级:解析出来但会被 clamp 夹到 L3。
|
||||
return LevelCritical, true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
switch source {
|
||||
case "cli", "webui":
|
||||
return LevelInteractive
|
||||
case "system":
|
||||
return LevelBackground
|
||||
}
|
||||
return DefaultLevel
|
||||
}
|
||||
|
||||
// inCriticalSection 报告运行任务是否处于不可抢占区。
|
||||
@ -517,43 +669,28 @@ func (a *Agent) inCriticalSection() bool {
|
||||
return a.currentOutputChannel == channelConsolidation
|
||||
}
|
||||
|
||||
// pickTaskIndex 返回下一个要执行的任务下标(设计文档 §4.1 的选择函数)。
|
||||
//
|
||||
// 排序键:优先级降序 → 入队时刻升序 → ID 升序。
|
||||
// 纯函数:便于对抢占/优先级矩阵做确定性单测。
|
||||
func pickTaskIndex(q []*Task) int {
|
||||
best := 0
|
||||
for i := 1; i < len(q); i++ {
|
||||
if taskBefore(q[i], q[best]) {
|
||||
best = i
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// taskBefore 报告 x 是否应先于 y 执行(按**有效**优先级)。
|
||||
func taskBefore(x, y *Task) bool {
|
||||
lx, ly := effectiveLevel(x), effectiveLevel(y)
|
||||
if lx != ly {
|
||||
return lx > ly
|
||||
}
|
||||
if !x.EnqueuedAt.Equal(y.EnqueuedAt) {
|
||||
return x.EnqueuedAt.Before(y.EnqueuedAt)
|
||||
}
|
||||
return x.ID < y.ID
|
||||
}
|
||||
|
||||
// newInputTask 把一个**排队输入**包装成任务(无级别)。
|
||||
func newInputTask(evt *agentIO.InputEvent) *Task {
|
||||
return &Task{Kind: TaskKindInput, Level: DefaultLevel, Event: evt, EnqueuedAt: time.Now()}
|
||||
return &Task{Class: TaskQueued, Kind: TaskKindInput, Event: evt, EnqueuedAt: time.Now()}
|
||||
}
|
||||
|
||||
// newInterruptTask 把一个中断请求包装成任务。
|
||||
// newInterruptTask 把一个中断请求包装成任务(带级别)。
|
||||
func newInterruptTask(evt *agentIO.InputEvent, level Level) *Task {
|
||||
return &Task{Kind: TaskKindInput, Level: level, Event: evt, EnqueuedAt: time.Now()}
|
||||
return &Task{Class: TaskInterrupt, Kind: TaskKindInput, Level: level, Event: evt, EnqueuedAt: time.Now()}
|
||||
}
|
||||
|
||||
// newSelfTask 包装内核自循环输入——它是**排队任务**:记忆整理/子任务通知
|
||||
// 不需要及时处理,可被任何中断打断。
|
||||
func newSelfTask(msg selfInputMsg) *Task {
|
||||
return &Task{Kind: TaskKindSelf, Level: DefaultLevel, Self: msg, EnqueuedAt: time.Now()}
|
||||
return &Task{Class: TaskQueued, Kind: TaskKindSelf, Self: msg, EnqueuedAt: time.Now()}
|
||||
}
|
||||
|
||||
// newKernelInterruptTask 构造一个**内核级中断**(L4)。
|
||||
//
|
||||
// 这是 L4 的唯一来源:panic 中断、内核事件中断(selfip)。
|
||||
// 插件永远拿不到这个入口——它不经 InjectOptions,也不经 proc 桥。
|
||||
func newKernelInterruptTask(evt *agentIO.InputEvent) *Task {
|
||||
return &Task{Class: TaskInterrupt, Kind: TaskKindInput, Level: LevelCritical, Event: evt, EnqueuedAt: time.Now()}
|
||||
}
|
||||
|
||||
// DumpScheduler 返回调度器的原子快照(供状态页/测试断言)。
|
||||
@ -565,9 +702,16 @@ func (a *Agent) DumpScheduler() SchedulerSnapshot {
|
||||
defer a.sched.mu.Unlock()
|
||||
snap := SchedulerSnapshot{Running: a.sched.running, Stats: a.sched.stats}
|
||||
snap.Queue = append(snap.Queue, a.sched.queue...)
|
||||
snap.PendingInterrupts = append(snap.PendingInterrupts, a.sched.pendingInterrupts...)
|
||||
snap.Immediate = a.sched.immediate
|
||||
for lv := LevelBackground; lv <= LevelCritical; lv++ {
|
||||
snap.InterruptQueues[lv] = append(snap.InterruptQueues[lv], a.sched.interruptQueues[lv]...)
|
||||
snap.PendingInterrupts = append(snap.PendingInterrupts, a.sched.interruptQueues[lv]...)
|
||||
}
|
||||
if a.sched.immediate != nil {
|
||||
snap.PendingInterrupts = append(snap.PendingInterrupts, a.sched.immediate)
|
||||
}
|
||||
snap.SuspendStack = append(snap.SuspendStack, a.sched.suspendStack...)
|
||||
snap.MaxSuspendDepth = a.sched.maxSuspendDepth
|
||||
snap.MaxInterruptFrames = a.sched.maxInterruptFrames
|
||||
return snap
|
||||
}
|
||||
|
||||
@ -630,6 +774,46 @@ func (a *Agent) pumpInbox() {
|
||||
}
|
||||
|
||||
// executeTask 执行一个任务(测试与旧调用方的入口);见 executeNewTask。
|
||||
// raiseKernelInterrupt 是 **L4 的唯一入口**:panic 中断与内核事件中断(selfip)。
|
||||
//
|
||||
// 它不经 io.InputChan(那是外部/插件输入),而是直接向调度器登记一条内核中断:
|
||||
// 级别固定 L4、不夹取、不受插件声明影响。这正是“L4 只有内核持有”的落点。
|
||||
//
|
||||
// 能否抢占由调度器按统一判据决定;若会抢占,则顺手取消可取消的 LLM 流式步骤
|
||||
// (与 interceptLoop 对插件中断的处理完全一致)。
|
||||
func (a *Agent) raiseKernelInterrupt(source, channel, text string) {
|
||||
if a.sched == nil {
|
||||
return
|
||||
}
|
||||
evt := &agentIO.InputEvent{
|
||||
Source: source,
|
||||
Type: "interrupt",
|
||||
OutputChannel: channel,
|
||||
Payload: map[string]interface{}{
|
||||
"content": text,
|
||||
"interrupt": true,
|
||||
"interrupt_source": source,
|
||||
"interrupt_channel": channel,
|
||||
"kernel": true,
|
||||
},
|
||||
}
|
||||
if a.sched.requestKernelPreempt(evt) {
|
||||
a.cancelCurrentLLM()
|
||||
}
|
||||
}
|
||||
|
||||
// reportTaskPanic 把一个任务 panic 报告成内核 L4 中断。
|
||||
//
|
||||
// 递归保护是**结构性**的:若 panic 的任务本身就是 L4 内核中断,则不再产生新的
|
||||
// L4——否则同一个 panic 会自我放大成中断风暴,与“内核事件”应有的语义相反。
|
||||
func (a *Agent) reportTaskPanic(t *Task, r interface{}) {
|
||||
if t.Class == TaskInterrupt && t.Level >= LevelCritical {
|
||||
return
|
||||
}
|
||||
a.raiseKernelInterrupt("kernel", "kernel",
|
||||
fmt.Sprintf("内核事件:任务 #%d 发生 panic:%v(该任务已被丢弃,调度器存活)", t.ID, r))
|
||||
}
|
||||
|
||||
func (a *Agent) executeTask(t *Task) {
|
||||
a.executeNewTask(t)
|
||||
}
|
||||
@ -647,6 +831,7 @@ func (a *Agent) executeNewTask(t *Task) {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[agent] task#%d (%s) panic recovered: %v\n%s",
|
||||
t.ID, t.Level, r, debug.Stack())
|
||||
a.reportTaskPanic(t, r)
|
||||
}
|
||||
}()
|
||||
switch t.Kind {
|
||||
@ -686,6 +871,7 @@ func (a *Agent) resumeTask(t *Task, f *TaskFrame) {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[agent] resume task#%d panic recovered: %v\n%s",
|
||||
t.ID, r, debug.Stack())
|
||||
a.reportTaskPanic(t, r)
|
||||
a.sched.done(t)
|
||||
}
|
||||
}()
|
||||
|
||||
@ -42,7 +42,7 @@ func TestPreempt_DeferredDuringToolExec(t *testing.T) {
|
||||
StageHost: sh,
|
||||
})
|
||||
|
||||
if _, _ = enqueueTask(t, a, LevelBackground, "qq", "低优先级任务"); true {
|
||||
if _, _ = enqueueQueued(t, a, "qq", "低优先级任务"); true {
|
||||
}
|
||||
lt, _, _ := a.sched.nextRef()
|
||||
done := make(chan struct{})
|
||||
@ -57,7 +57,7 @@ func TestPreempt_DeferredDuringToolExec(t *testing.T) {
|
||||
// 工具执行中注入 L4 中断。
|
||||
intrEvt, _ := textEvent("cli", "紧急打断")
|
||||
intrEvt.Payload["interrupt"] = true
|
||||
if !a.sched.requestPreempt(intrEvt, LevelCritical) {
|
||||
if !a.sched.requestKernelPreempt(intrEvt) {
|
||||
t.Fatal("L4 应 arm 让位信号")
|
||||
}
|
||||
// 关键断言:信号已 arm,但任务仍在工具里 —— 绝不能挂起。
|
||||
@ -133,7 +133,7 @@ func TestBatch_NotAbandonedWithoutPreemption(t *testing.T) {
|
||||
StageHost: sh,
|
||||
})
|
||||
|
||||
if _, _ = enqueueTask(t, a, LevelBackground, "cli", "跑两个工具"); true {
|
||||
if _, _ = enqueueQueued(t, a, "cli", "跑两个工具"); true {
|
||||
}
|
||||
tt, _, _ := a.sched.nextRef()
|
||||
a.executeNewTask(tt)
|
||||
|
||||
@ -74,8 +74,12 @@ func TestScheduler_StressMixedLoad(t *testing.T) {
|
||||
for i := 0; i < nInputs; i++ {
|
||||
a.io.InjectInput("cli", "text", map[string]interface{}{"content": fmt.Sprintf("msg-%d", i)})
|
||||
}
|
||||
// 中断按 L1/L2/L3 轮转:把“四条中断队列按级别高→低扫描”真正压上,
|
||||
// 而不只是排空一条队列。
|
||||
levels := []string{"L1", "L2", "L3"}
|
||||
for i := 0; i < nInterrupts; i++ {
|
||||
a.io.InjectInterruptText("qq", "cli", fmt.Sprintf("intr-%d", i))
|
||||
a.io.InjectInterruptTextOpts("qq", "cli", fmt.Sprintf("intr-%d", i),
|
||||
agentIO.InjectOptions{Priority: levels[i%len(levels)]})
|
||||
}
|
||||
|
||||
snap := waitQuiescent(t, a, nInputs+nInterrupts, 30*time.Second)
|
||||
@ -129,7 +133,7 @@ func TestObservability_SchedulerEventsAndStatus(t *testing.T) {
|
||||
|
||||
intrEvt, _ := textEvent("cli", "紧急")
|
||||
intrEvt.Payload["interrupt"] = true
|
||||
a.sched.requestPreempt(intrEvt, LevelCritical)
|
||||
a.sched.requestKernelPreempt(intrEvt)
|
||||
a.cancelCurrentLLM()
|
||||
<-done
|
||||
|
||||
@ -185,16 +189,18 @@ func TestE2E_RealLoopPreemption(t *testing.T) {
|
||||
a.Start()
|
||||
defer a.Stop()
|
||||
|
||||
// L1:qq 入站消息 → 阻塞在第一次 LLM 调用
|
||||
a.io.InjectInput("qq", "text", map[string]interface{}{"content": "低优先级长任务"})
|
||||
// 排队输入:qq 入站消息 → 阻塞在第一次 LLM 调用(排队任务无级别)
|
||||
a.io.InjectInput("qq", "text", map[string]interface{}{"content": "长任务"})
|
||||
select {
|
||||
case <-sp.entered:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("低优先级任务未进入 LLM")
|
||||
}
|
||||
|
||||
// L4:cli 紧急打断 → interceptLoop 应取消 LLM、登记抢占
|
||||
a.io.InjectInterruptText("cli", "cli", "紧急打断")
|
||||
// 插件声明的 L3 中断:interceptLoop 应取消 LLM 并登记抢占。
|
||||
// 注意它能打断**排队任务**不是因为级别高,而是因为排队任务无级别——
|
||||
// 任何中断都大于它。
|
||||
a.io.InjectInterruptTextOpts("cli", "cli", "紧急打断", agentIO.InjectOptions{Priority: "L3"})
|
||||
a.io.InjectInput("cli", "text", map[string]interface{}{"content": "后续常规输入"})
|
||||
|
||||
// 排空:中断任务 + 被恢复的原任务 + 后续常规输入
|
||||
|
||||
206
internal/agent/core/scheduler_kernel_test.go
Normal file
206
internal/agent/core/scheduler_kernel_test.go
Normal file
@ -0,0 +1,206 @@
|
||||
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 必须被夹到 L3:L4 是内核的调度内部属性,不接受外部越权。
|
||||
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 为 nil:handleInput 解引用即 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,期望 L3(payload=%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")
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -74,7 +74,7 @@ func TestStack_NestedPreemptionResumesLIFO(t *testing.T) {
|
||||
a := newPreemptAgent(t, sp)
|
||||
|
||||
// A(L1)开始运行
|
||||
if _, _ = enqueueTask(t, a, LevelBackground, "qq", "任务A"); true {
|
||||
if _, _ = enqueueQueued(t, a, "qq", "任务A"); true {
|
||||
}
|
||||
at, _, _ := a.sched.nextRef()
|
||||
doneA := make(chan struct{})
|
||||
@ -94,7 +94,7 @@ func TestStack_NestedPreemptionResumesLIFO(t *testing.T) {
|
||||
|
||||
// B 开始运行
|
||||
bt, _, k := a.sched.nextRef()
|
||||
if k != nextPending || bt.Level != LevelMessage {
|
||||
if k != nextImmediate || bt.Level != LevelMessage {
|
||||
t.Fatalf("应取到 B(pending),kind=%v level=%v", k, bt.Level)
|
||||
}
|
||||
doneB := make(chan struct{})
|
||||
@ -113,8 +113,8 @@ func TestStack_NestedPreemptionResumesLIFO(t *testing.T) {
|
||||
if len(snap.SuspendStack) != 2 {
|
||||
t.Fatalf("嵌套后栈深=%d,期望 2", len(snap.SuspendStack))
|
||||
}
|
||||
if snap.SuspendStack[0].Task.Level != LevelBackground {
|
||||
t.Fatalf("栈底应为 A(L1),实际 %v", snap.SuspendStack[0].Task.Level)
|
||||
if snap.SuspendStack[0].Task.Class != TaskQueued {
|
||||
t.Fatalf("栈底应为排队任务 A(无级别),实际 %v", snap.SuspendStack[0].Task.Class)
|
||||
}
|
||||
if snap.SuspendStack[1].Task.Level != LevelMessage {
|
||||
t.Fatalf("栈顶应为 B(L2),实际 %v", snap.SuspendStack[1].Task.Level)
|
||||
@ -122,7 +122,7 @@ func TestStack_NestedPreemptionResumesLIFO(t *testing.T) {
|
||||
|
||||
// C 运行完毕(第三次调用,不阻塞)
|
||||
ct, _, k := a.sched.nextRef()
|
||||
if k != nextPending || ct.Level != LevelInteractive {
|
||||
if k != nextImmediate || ct.Level != LevelInteractive {
|
||||
t.Fatalf("应取到 C,kind=%v level=%v", k, ct.Level)
|
||||
}
|
||||
a.executeNewTask(ct)
|
||||
@ -141,8 +141,8 @@ func TestStack_NestedPreemptionResumesLIFO(t *testing.T) {
|
||||
if k2 != nextSuspended {
|
||||
t.Fatalf("应继续恢复 A,kind=%v", k2)
|
||||
}
|
||||
if rt2.Level != LevelBackground {
|
||||
t.Fatalf("最后应恢复 A(L1),实际 %v", rt2.Level)
|
||||
if rt2.Class != TaskQueued {
|
||||
t.Fatalf("最后应恢复排队的 A(无级别),实际 %v", rt2.Class)
|
||||
}
|
||||
a.resumeTask(rt2, rf2)
|
||||
|
||||
@ -159,9 +159,9 @@ func TestStack_TopOnlyWinsOverHigherPrioritySuspended(t *testing.T) {
|
||||
// (栈自底向上基础级递增),这里专门用来区分两种实现:
|
||||
// · 只比栈顶 → 取 B
|
||||
// · 全栈扫最优 → 取 A(L3 > L2)
|
||||
a.sched.suspend(&Task{ID: 1, Level: LevelInteractive, EnqueuedAt: time.Now()},
|
||||
a.sched.suspend(&Task{ID: 1, Class: TaskInterrupt, Level: LevelInteractive, EnqueuedAt: time.Now()},
|
||||
a.newTaskFrame("A", a.stageCtxFromInput("A", "", "")))
|
||||
a.sched.suspend(&Task{ID: 2, Level: LevelMessage, EnqueuedAt: time.Now()},
|
||||
a.sched.suspend(&Task{ID: 2, Class: TaskInterrupt, Level: LevelMessage, EnqueuedAt: time.Now()},
|
||||
a.newTaskFrame("B", a.stageCtxFromInput("B", "", "")))
|
||||
|
||||
rt, _, k := a.sched.nextRef()
|
||||
@ -180,13 +180,13 @@ func TestStack_TopOnlyWinsOverHigherPrioritySuspended(t *testing.T) {
|
||||
func TestStack_DepthCapDuringNesting(t *testing.T) {
|
||||
a := newPreemptAgent(t, &scriptProvider{})
|
||||
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: Level(i + 1)}, frame())
|
||||
for i := 0; i < a.sched.maxInterruptFrames; i++ {
|
||||
a.sched.suspend(&Task{ID: uint64(i + 1), Class: TaskInterrupt, Level: Level(i + 1)}, frame())
|
||||
}
|
||||
if a.sched.canSuspend() {
|
||||
t.Fatal("栈已满,canSuspend 应为 false")
|
||||
}
|
||||
if n := len(a.DumpScheduler().SuspendStack); n != a.sched.maxSuspendDepth {
|
||||
t.Fatalf("栈深=%d,期望上限 %d", n, a.sched.maxSuspendDepth)
|
||||
if n := len(a.DumpScheduler().SuspendStack); n != a.sched.maxInterruptFrames {
|
||||
t.Fatalf("栈深=%d,期望上界 %d", n, a.sched.maxInterruptFrames)
|
||||
}
|
||||
}
|
||||
|
||||
@ -14,7 +14,7 @@ import (
|
||||
)
|
||||
|
||||
func TestStarvation_EffectiveLevelPromotion(t *testing.T) {
|
||||
base := &Task{Level: LevelBackground}
|
||||
base := &Task{Class: TaskInterrupt, Level: LevelBackground}
|
||||
if got := effectiveLevel(base); got != LevelBackground {
|
||||
t.Fatalf("未抢占时有效级=%v,期望 L1", got)
|
||||
}
|
||||
@ -33,7 +33,7 @@ func TestStarvation_EffectiveLevelPromotion(t *testing.T) {
|
||||
}
|
||||
|
||||
// 封顶 L4:L3 任务被多次抢占也不会超过紧急级。
|
||||
high := &Task{Level: LevelInteractive, PreemptCount: 99}
|
||||
high := &Task{Class: TaskInterrupt, Level: LevelInteractive, PreemptCount: 99}
|
||||
if got := effectiveLevel(high); got != LevelCritical {
|
||||
t.Fatalf("L3 提升后应封顶为 L4,实际 %v", got)
|
||||
}
|
||||
@ -42,8 +42,8 @@ func TestStarvation_EffectiveLevelPromotion(t *testing.T) {
|
||||
func TestStarvation_CooldownBlocksImmediateRepreempt(t *testing.T) {
|
||||
a := newPreemptAgent(t, newPreemptProvider())
|
||||
|
||||
low := &Task{ID: 1, Level: LevelBackground, EnqueuedAt: time.Now()}
|
||||
a.sched.enqueue(low)
|
||||
low := &Task{ID: 1, Class: TaskInterrupt, Level: LevelBackground, EnqueuedAt: time.Now()}
|
||||
a.sched.immediate = low
|
||||
a.sched.nextRef() // running = low
|
||||
|
||||
e1, _ := textEvent("qq", "第一次打断")
|
||||
@ -64,7 +64,7 @@ func TestStarvation_CooldownBlocksImmediateRepreempt(t *testing.T) {
|
||||
a.sched.mu.Unlock()
|
||||
|
||||
e2, _ := textEvent("cli", "冷却期内的紧急打断")
|
||||
if a.sched.requestPreempt(e2, LevelCritical) {
|
||||
if a.sched.requestKernelPreempt(e2) {
|
||||
t.Fatal("抢占冷却期内不得再抢占")
|
||||
}
|
||||
if a.sched.preemptGrantedFor() {
|
||||
@ -75,8 +75,8 @@ func TestStarvation_CooldownBlocksImmediateRepreempt(t *testing.T) {
|
||||
func TestStarvation_PromotionBlocksSameLevelPreempt(t *testing.T) {
|
||||
a := newPreemptAgent(t, newPreemptProvider())
|
||||
|
||||
low := &Task{ID: 1, Level: LevelBackground, EnqueuedAt: time.Now()}
|
||||
a.sched.enqueue(low)
|
||||
low := &Task{ID: 1, Class: TaskInterrupt, Level: LevelBackground, EnqueuedAt: time.Now()}
|
||||
a.sched.immediate = low
|
||||
a.sched.nextRef()
|
||||
// 模拟「已被抢占过一次」:有效级 = L2。
|
||||
low.PreemptCount = 1
|
||||
@ -96,23 +96,29 @@ func TestStarvation_PromotionBlocksSameLevelPreempt(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 选择函数必须用有效级:被抢占过的任务在排队时应当优先于同级/更低的任务。
|
||||
func TestStarvation_SelectionUsesEffectiveLevel(t *testing.T) {
|
||||
base := time.Now()
|
||||
promoted := &Task{ID: 1, Level: LevelBackground, PreemptCount: 2, EnqueuedAt: base} // 有效 L3
|
||||
normal := &Task{ID: 2, Level: LevelMessage, EnqueuedAt: base.Add(time.Second)} // L2
|
||||
// 提升必须真的进入抢占判据,而不只是一个数学性质:
|
||||
// 被抢占过一次的 L1 中断(有效 L2)应当顶住同级 L2 流的再次抢占。
|
||||
func TestStarvation_PromotionIsVisibleInSelection(t *testing.T) {
|
||||
a := newPreemptAgent(t, newPreemptProvider())
|
||||
|
||||
if !taskBefore(promoted, normal) {
|
||||
t.Fatal("被抢占 2 次的 L1(有效 L3)应先于 L2 执行")
|
||||
}
|
||||
if taskBefore(normal, promoted) {
|
||||
t.Fatal("选择函数不得只看基础级")
|
||||
// 栈里放一个「被抢占过一次的 L1」:有效级 L2。
|
||||
a.sched.suspend(&Task{ID: 1, Class: TaskInterrupt, Level: LevelBackground, PreemptCount: 1},
|
||||
a.newTaskFrame("A", a.stageCtxFromInput("A", "", "")))
|
||||
|
||||
// 队列里来一个 L2:有效级持平(2 vs 2)→ 不得越过栈顶。
|
||||
evt, _ := textEvent("qq", "L2 中断")
|
||||
a.sched.registerInterrupt(newInterruptTask(evt, LevelMessage))
|
||||
if _, _, kind := a.sched.nextRef(); kind != nextSuspended {
|
||||
t.Fatalf("有效级持平应恢复栈顶,kind=%v", kind)
|
||||
}
|
||||
|
||||
// 提升不改变调度器自身的排序稳定性:同为有效级时按入队时刻。
|
||||
a := &Task{ID: 3, Level: LevelBackground, PreemptCount: 1, EnqueuedAt: base.Add(2 * time.Second)} // 有效 L2
|
||||
b := &Task{ID: 4, Level: LevelMessage, EnqueuedAt: base.Add(time.Second)} // L2,更早
|
||||
if !taskBefore(b, a) {
|
||||
t.Fatal("同有效级时应先到先服务")
|
||||
// 队列里来一个 L3:严格大于 → 队头优先。
|
||||
// PreemptCount 从 0 起:suspend 内部会 +1 → 有效级 L2(正好用来卡 L2 持平)。
|
||||
a.sched.suspend(&Task{ID: 2, Class: TaskInterrupt, Level: LevelBackground},
|
||||
a.newTaskFrame("B", a.stageCtxFromInput("B", "", "")))
|
||||
evt2, _ := textEvent("cli", "L3 中断")
|
||||
a.sched.registerInterrupt(newInterruptTask(evt2, LevelInteractive))
|
||||
if _, _, kind := a.sched.nextRef(); kind != nextInterrupt {
|
||||
t.Fatalf("L3 > 有效 L2 应取中断队列,kind=%v", kind)
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,83 +5,120 @@ package core
|
||||
// 设计依据 docs/zh/input-scheduler-design.md §11.4(Q1/Q4)与 §11.5(O1/K1)。
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||
)
|
||||
|
||||
func mkTask(id uint64, level Level, at time.Time) *Task {
|
||||
return &Task{ID: id, Level: level, EnqueuedAt: at}
|
||||
}
|
||||
// 新模型的选择顺序:immediate → 中断队列 L4→L1 → 栈顶(与队头比级别) → 排队 FIFO。
|
||||
func TestScheduler_SelectionOrder(t *testing.T) {
|
||||
s := newScheduler(16)
|
||||
|
||||
// Q1:选择函数的排序键是 (-Level, EnqueuedAt, ID)。
|
||||
func TestPickTaskIndex_Ordering(t *testing.T) {
|
||||
base := time.Date(2026, 9, 12, 12, 0, 0, 0, time.UTC)
|
||||
// 四条中断队列各放一个,入队顺序与级别相反 —— 验证“按级别扫”而非 FIFO。
|
||||
for _, lv := range []Level{LevelBackground, LevelMessage, LevelInteractive, LevelCritical} {
|
||||
evt, _ := textEvent("qq", "中断")
|
||||
s.registerInterrupt(newInterruptTask(evt, lv))
|
||||
}
|
||||
// 排队任务两条(无级别,FIFO)。
|
||||
s.enqueue(newSelfTask(selfInputMsg{text: "q1"}))
|
||||
s.enqueue(newSelfTask(selfInputMsg{text: "q2"}))
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
queue []*Task
|
||||
want []uint64
|
||||
}{
|
||||
{
|
||||
name: "高优先级先执行,与入队先后无关",
|
||||
queue: []*Task{
|
||||
mkTask(1, LevelBackground, base),
|
||||
mkTask(2, LevelCritical, base.Add(time.Second)),
|
||||
mkTask(3, LevelMessage, base.Add(2*time.Second)),
|
||||
},
|
||||
want: []uint64{2, 3, 1},
|
||||
},
|
||||
{
|
||||
name: "同优先级先到先服务",
|
||||
queue: []*Task{
|
||||
mkTask(1, LevelInteractive, base.Add(3*time.Second)),
|
||||
mkTask(2, LevelInteractive, base.Add(time.Second)),
|
||||
mkTask(3, LevelInteractive, base.Add(2*time.Second)),
|
||||
},
|
||||
want: []uint64{2, 3, 1},
|
||||
},
|
||||
{
|
||||
name: "同优先级同入队时刻用 ID 兜底(保证确定性)",
|
||||
queue: []*Task{
|
||||
mkTask(7, LevelMessage, base),
|
||||
mkTask(3, LevelMessage, base),
|
||||
mkTask(5, LevelMessage, base),
|
||||
},
|
||||
want: []uint64{3, 5, 7},
|
||||
},
|
||||
{
|
||||
name: "四级全覆盖",
|
||||
queue: []*Task{
|
||||
mkTask(1, LevelBackground, base),
|
||||
mkTask(2, LevelMessage, base),
|
||||
mkTask(3, LevelInteractive, base),
|
||||
mkTask(4, LevelCritical, base),
|
||||
},
|
||||
want: []uint64{4, 3, 2, 1},
|
||||
},
|
||||
var order []Level
|
||||
for i := 0; i < 4; i++ {
|
||||
task, _, kind := s.nextRef()
|
||||
if kind != nextInterrupt {
|
||||
t.Fatalf("第 %d 个应来自中断队列,kind=%v", i+1, kind)
|
||||
}
|
||||
order = append(order, task.Level)
|
||||
s.done(task)
|
||||
}
|
||||
want := []Level{LevelCritical, LevelInteractive, LevelMessage, LevelBackground}
|
||||
for i := range want {
|
||||
if order[i] != want[i] {
|
||||
t.Fatalf("中断执行顺序=%v,期望 %v", order, want)
|
||||
}
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
q := append([]*Task(nil), c.queue...)
|
||||
var got []uint64
|
||||
for len(q) > 0 {
|
||||
i := pickTaskIndex(q)
|
||||
got = append(got, q[i].ID)
|
||||
q = append(q[:i], q[i+1:]...)
|
||||
}
|
||||
if len(got) != len(c.want) {
|
||||
t.Fatalf("取出的任务数=%d,期望 %d", len(got), len(c.want))
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != c.want[i] {
|
||||
t.Fatalf("执行顺序=%v,期望 %v", got, c.want)
|
||||
}
|
||||
}
|
||||
// 中断耗尽后才是排队任务,且保持 FIFO。
|
||||
for i := 1; i <= 2; i++ {
|
||||
task, _, kind := s.nextRef()
|
||||
if kind != nextReady {
|
||||
t.Fatalf("中断耗尽后应取排队任务,kind=%v", kind)
|
||||
}
|
||||
if task.Self.text != fmt.Sprintf("q%d", i) {
|
||||
t.Fatalf("排队任务应 FIFO,第 %d 个=%q", i, task.Self.text)
|
||||
}
|
||||
s.done(task)
|
||||
}
|
||||
if _, _, kind := s.nextRef(); kind != nextNone {
|
||||
t.Fatal("全空后应返回 nextNone")
|
||||
}
|
||||
}
|
||||
|
||||
// immediate(刚抢占成功的中断)必须最先运行——哪怕队列里有更高级别的待处理中断。
|
||||
// 这是“抢占立即生效”的实现方式,也是它不需要和栈顶比级别的原因。
|
||||
func TestScheduler_ImmediateWins(t *testing.T) {
|
||||
s := newScheduler(16)
|
||||
evt1, _ := textEvent("cli", "L4 待处理")
|
||||
s.registerInterrupt(newKernelInterruptTask(evt1))
|
||||
|
||||
evt2, _ := textEvent("qq", "抢占者")
|
||||
preemptor := newInterruptTask(evt2, LevelBackground)
|
||||
s.mu.Lock()
|
||||
s.setImmediateLocked(preemptor)
|
||||
s.mu.Unlock()
|
||||
|
||||
task, _, kind := s.nextRef()
|
||||
if kind != nextImmediate || task != preemptor {
|
||||
t.Fatalf("immediate 必须先运行,kind=%v", kind)
|
||||
}
|
||||
}
|
||||
|
||||
// 中断队列头与中断栈顶比级别,取高者;栈顶是排队任务(无级别)时任何中断都赢。
|
||||
func TestScheduler_StackTopVsInterruptQueue(t *testing.T) {
|
||||
s := newScheduler(16)
|
||||
// 直接构造挂起现场:不走 suspend(),避免 PreemptCount/冷却干扰本用例
|
||||
// (本用例只测“选择顺序”这一件事)。
|
||||
pushSuspended := func(id uint64, class TaskClass, lv Level) {
|
||||
s.mu.Lock()
|
||||
s.suspendStack = append(s.suspendStack, &suspendedTask{
|
||||
Task: &Task{ID: id, Class: class, Level: lv}, Frame: &TaskFrame{},
|
||||
})
|
||||
s.mu.Unlock()
|
||||
}
|
||||
// 每次选取后清掉 running,让下一次 registerInterrupt 不把它当成运行任务。
|
||||
clearRunning := func() {
|
||||
s.mu.Lock()
|
||||
s.running = nil
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// 栈顶 L3,队列只有 L2 → 恢复栈顶。
|
||||
pushSuspended(1, TaskInterrupt, LevelInteractive)
|
||||
evt, _ := textEvent("qq", "L2 待处理")
|
||||
s.registerInterrupt(newInterruptTask(evt, LevelMessage))
|
||||
if _, _, kind := s.nextRef(); kind != nextSuspended {
|
||||
t.Fatalf("栈顶 L3 > 队头 L2 → 应恢复栈顶,kind=%v", kind)
|
||||
}
|
||||
clearRunning()
|
||||
|
||||
// 栈顶 L3,队列来了 L4 → 队头优先。
|
||||
pushSuspended(2, TaskInterrupt, LevelInteractive)
|
||||
evt2, _ := textEvent("cli", "L4 待处理")
|
||||
s.registerInterrupt(newKernelInterruptTask(evt2))
|
||||
if _, _, kind := s.nextRef(); kind != nextInterrupt {
|
||||
t.Fatalf("队头 L4 > 栈顶 L3 → 应先取中断,kind=%v", kind)
|
||||
}
|
||||
clearRunning()
|
||||
|
||||
// 栈顶是排队任务(无级别)→ 任何中断都赢。
|
||||
pushSuspended(3, TaskQueued, 0)
|
||||
evt3, _ := textEvent("qq", "L1 待处理")
|
||||
s.registerInterrupt(newInterruptTask(evt3, LevelBackground))
|
||||
if _, _, kind := s.nextRef(); kind != nextInterrupt {
|
||||
t.Fatalf("排队栈顶可被任何中断打断,kind=%v", kind)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -126,7 +126,6 @@ type TaskFrame struct {
|
||||
IsInterrupt bool
|
||||
StartedAt time.Time
|
||||
Terminal taskTerminal
|
||||
Level Level
|
||||
|
||||
// PrefixLen 是 stepPrepare 构建的**基础前缀**长度(system + timeline + 用户输入)。
|
||||
// 恢复时用它把「本任务自己的现场」接回重建后的前缀之上(见 rebaseFramePrefix)。
|
||||
@ -382,7 +381,6 @@ func (a *Agent) prepareInputTask(evt *agentIO.InputEvent) (*TaskFrame, taskTermi
|
||||
f.CleanInput = cleanInput
|
||||
f.IsInterrupt = isInterrupt
|
||||
f.StartedAt = start
|
||||
f.Level = a.sched.currentLevel()
|
||||
return f, terminalNone
|
||||
}
|
||||
|
||||
|
||||
@ -22,7 +22,7 @@ func TestTerminal_TaskScopedReplyNotMisrouted(t *testing.T) {
|
||||
a := newPreemptAgent(t, sp)
|
||||
|
||||
lowEvt, lowCh := textEvent("qq", "低优先级任务")
|
||||
lowTask := &Task{Kind: TaskKindInput, Level: LevelBackground, Event: lowEvt, EnqueuedAt: time.Now()}
|
||||
lowTask := newInputTask(lowEvt)
|
||||
if !a.sched.enqueue(lowTask) {
|
||||
t.Fatal("入队失败")
|
||||
}
|
||||
@ -38,8 +38,8 @@ func TestTerminal_TaskScopedReplyNotMisrouted(t *testing.T) {
|
||||
|
||||
intrEvt, intrCh := textEvent("cli", "紧急打断")
|
||||
intrEvt.Payload["interrupt"] = true
|
||||
if !a.sched.requestPreempt(intrEvt, LevelCritical) {
|
||||
t.Fatal("L4 应抢占 L1")
|
||||
if !a.sched.requestKernelPreempt(intrEvt) {
|
||||
t.Fatal("内核 L4 应抢占排队任务")
|
||||
}
|
||||
a.cancelCurrentLLM()
|
||||
select {
|
||||
@ -50,8 +50,8 @@ func TestTerminal_TaskScopedReplyNotMisrouted(t *testing.T) {
|
||||
|
||||
// 执行中断任务 → 只应写它自己的回执通道。
|
||||
it, _, k := a.sched.nextRef()
|
||||
if k != nextPending {
|
||||
t.Fatalf("应取到 pending 中断,kind=%v", k)
|
||||
if k != nextImmediate {
|
||||
t.Fatalf("应取到立即运行的中断,kind=%v", k)
|
||||
}
|
||||
a.executeNewTask(it)
|
||||
if len(intrCh) != 1 {
|
||||
|
||||
@ -266,6 +266,10 @@ func applyInjectOpts(payload map[string]interface{}, opts InjectOptions) {
|
||||
if opts.CleanerName != "" {
|
||||
payload["cleaner_name"] = opts.CleanerName
|
||||
}
|
||||
// priority 只对中断注入有意义;排队路径会忽略它(内核侧只读不写)。
|
||||
if opts.Priority != "" {
|
||||
payload["priority"] = opts.Priority
|
||||
}
|
||||
}
|
||||
|
||||
func (m *IOManager) InjectInputOpts(source, eventType string, payload map[string]interface{}, opts InjectOptions) {
|
||||
|
||||
Reference in New Issue
Block a user