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

@ -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、
// 任务级回执在 M3M6 加入
// 类别由**用哪个注入 API**决定,与通道名无关:
// - TaskInterruptInjectInterrupt* 注入。带级别 L1..L4可抢占
// 可被更高级中断打断(被打断的现场压入**中断栈**
// - TaskQueuedInjectText*/InjectInputSync* 与内核自循环。**无级别**
// 用于“不需及时处理”的场景,可被**任何**中断打断。
//
// 并发模型(不变量 I2readyQueue/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.PriorityL1..L3
// - 内核内部产生 L4panic / 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空/非法一律降到 DefaultLevelL1
// **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 为人机交互L3system/_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)
}
}()