Files
HomeAgent/internal/agent/core/interrupt.go
JianFeeeee b829968ac6 refactor(agent/core): 中断子系统独立成 interrupt.go + 修两处真实数据竞争
## 中断子系统拆分(用户按 OS 中断语义三次裁定的最终边界)

中断类比 OS:只提供中断优先级、中断队列、现场保护、中断屏蔽。
调度器建立在其之上——它问"中断侧该出谁",然后执行那个任务。

新增 internal/agent/core/interrupt.go(303 行),承载:
- 中断队列(interruptQueues[5] + immediate 单槽)
- 中断栈(suspendStack)—— **唯一操作者**
- 中断屏蔽(critical,对应关中断)
- 让位信号原语(preemptArmed/preemptLevel)

留在 scheduler.go 的是**策略**:
- 任务优先级语义(Level / effectiveLevel / canPreempt / clampPluginLevel)
- 恢复决策(nextInterruptSelection:栈顶 vs 中断队列比较)
- 就绪队列 / 停止配额 / 背压

### 关键设计:中断栈只有中断管理器能操作

调度器通过 stackTopLocked() **只读查询**栈顶做比较,
真正的弹栈由 restoreTopLocked() 在中断管理器内完成。
这条不变量在类型层面无法绕过。

### 解耦机制:stackNotify 钩子

中断栈变化时通告调度器,调度器据此唤醒调度循环重新求值。
中断管理器不知道调度器会怎么选,调度器也不碰栈内部 ⇒
将来给中断子系统加向量表或换多栈策略,不必改调度器。

### 测试

新增 interrupt_invariant_test.go:I1–I7 共 11 条**不变量**判据
(区别于既有的行为测试)。两条经变异验证确认会变红:
- canPreempt 改成恒 true → L4 不抢 L4 判据红
- maxInterruptFrames 改成 99 → 上界判据红
- 去掉 notifyStackLocked → 钩子判据红(且判据本身带超时,缺判据是"红"不是"挂")

## 修两处真实数据竞争(阻塞 CI,非本次引入)

-race 实测:internal/agent/core 原有 30 个测试失败,整包被判红。

根因(ab27d1a 上已存在,与拆分无关):
- core/stages.go:withExecuteCtx 往调用方共享的 tc.Arguments 原地写 ctx
- io/channel.go:bindExecCtx 同样原地写设备 args

原注释称"args 每次调用独立、天然一 goroutine 一份",但代码改的是调用方那份。
工具在自己的 goroutine 里跑,超时/取消路径下主 goroutine 仍在读同一份 args。

修法:复制 map 而非原地写。变异验证:退回原地写后
TestPreempt_DeferredDuringToolExec 立刻报 DATA RACE。

## 验证

- go build ./... 通过
- go test -p 1 ./... 全仓无失败
- -race 三关键包(core/io/proc)全绿,core 从 30 红归零
- CI 门禁自检 / 启动图判据 / 版本一致性 本地通过

## 遗留(不在本次)

- 中断向量表(计划 §7 已设计,按 §7.6 应在拆分后单独做)
- maxQueue 仍约束中断队列:OS 语义上说不通,但改它是行为变更,单独立项
2026-10-25 13:19:31 +08:00

346 lines
12 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
// 中断子系统 —— 只做三件事:**按优先级打断、保护被打断者的现场、恢复它**。
//
// ## 边界(2026-10-25 用户裁定,OS 语义)
//
// 中断类似操作系统中的中断语义,只提供:
//
// · 中断优先级 —— 谁打断谁的唯一依据(Level / effectiveLevel / canPreempt)
// · 中断队列 —— 待处理中断按优先级分组排队(interruptQueues / immediate)
// · 现场保护 —— 被打断任务的现场压栈、以及**恢复决策**(suspendStack)
// · 中断屏蔽 —— 不可抢占临界区(critical,对应 CPU 的关中断)
//
// **不含**:就绪队列、停止配额、任务 ID 分配、统计、背压、"执行任务"本身。
// 调度器建立在本子系统之上——它问"中断侧该出谁",然后执行那个任务。
//
// ## 职责边界(不要越界)
//
// 本文件**只提供机制,不做决策**:
//
// 进中断 —— enqueueInterruptLocked / setImmediateLocked
// 中断栈 —— suspend(压栈)/ 弹栈原语
// 中断屏蔽 —— critical
// 让位信号 —— preemptArmed/preemptLevel 的读写原语
//
// 而**策略**住在 scheduler.go:
//
// 谁比谁优先 —— effectiveLevel / canPreempt / clampPluginLevel
// 能不能打断 —— registerInterrupt(含冷却期/临界区判定)
// 该恢复谁 —— nextInterruptSelection(栈顶 vs 中断队列比较)
//
// 换句话说:调度器问"现在恢复谁",然后调用这里的原语执行。
// 中断管理器**不自行比较优先级**,也不看就绪队列。
//
// ## 核心不变量
//
// 链条 = 就绪(L0) ← I(L1) ← I(L2) ← I(L3) ← I(L4 运行中),被挂起 4 帧
//
// 即 maxInterruptFrames = int(LevelCritical)。这是**结构推论**,不是配置项:
// L4 之上没有更高级别,链到此为止。超限只可能是内核 bug,因此只做防御性计数,
// **不降级、不丢弃帧**。
//
// 判据见 interrupt_invariant_test.go(I1..I6)。
import (
"sync/atomic"
"time"
)
// interruptState 是中断子系统的全部状态。
//
// 内嵌进 scheduler(字段提升),因此访问写法仍是 s.interruptQueues 等——
// 这样拆分对既有代码与测试透明,风险最低。
//
// ⚠️ 这些字段全部由 scheduler.mu 保护,与就绪队列共用同一把锁:
// 中断判定需要看"当前运行任务/是否临界区",与调度状态在同一临界区内被
// 一起读写。拆锁是并发模型变更,不属于本次重构。
type interruptState struct {
// interruptQueues[level]:四条**中断队列**(level 1..4),同级 FIFO。
// 未能立即抢占的中断(级别不足,或运行任务在临界区)按级别入队。
interruptQueues [5][]*Task
// immediate:已判定的最高优先待运行者。抢占必须**立即生效**,
// 所以它不经队列,在下一个安全点直接运行。
// 这也消除了"抢占者与被抢占者同级"的比较问题——抢占者不需和栈顶比。
immediate *Task
// suspendStack:**中断栈**。被抢占后保存现场的任务压栈(LIFO)。
// 只有**栈顶**参与恢复选择,栈内不做优先级重排。
suspendStack []*suspendedTask
// preemptArmed/preemptLevel:运行任务的"让位信号"。
// 表示"已有更高优先级的中断被判定,请在下一个安全点让位"。
preemptArmed bool
preemptLevel Level
// critical 报告运行任务是否在不可抢占临界区(如记忆整理)。
// 对应 CPU 的关中断:此时中断只能入队,不能抢占。
// 由于 interruptLoop 也要读它,必须是原子的。
critical atomic.Bool
// maxInterruptFrames 是中断栈帧数的**结构上界**(= 中断级数,非配置项)。
// 见文件头的核心不变量。
maxInterruptFrames int
// stackNotify 是**中断栈状态钩子**:栈发生变化时通告调度器。
//
// 为什么需要它(用户裁定):中断栈的操作者只有中断管理器;调度器不做
// 压栈/弹栈,但它必须知道"栈里有没有可恢复的现场、栈顶是什么级别",
// 否则无法决定"该从队列取哪个优先级的任务"。钩子就是这个通告通道。
//
// 约定:**在持锁状态下调用**,回调不得再取 s.mu(否则自死锁)。
// 实现方只做"记下状态 + 唤醒调度循环",不做决策。
stackNotify func()
}
// suspendedTask 是一个被抢占任务的现场。
//
// 现场保护的粒度是**整个 TaskFrame**(含 f.Msgs):恢复时不能复用超限/过期的
// 那份消息数组,必须由 rebaseFramePrefix 重建(见 resumeTask 的注释)。
type suspendedTask struct {
Task *Task
Frame *TaskFrame
}
// nextSelection 标识 nextRef 从哪个集合取出任务。
type nextSelection int
const (
nextNone nextSelection = iota
nextReady
nextInterrupt
nextImmediate
nextSuspended
)
// ── 中断队列操作 ──
// 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
}
// enqueueInterruptLocked 把中断按级别入队。
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
}
}
}
lv := t.Level
if lv < LevelBackground || lv > LevelCritical {
lv = DefaultLevel
}
s.interruptQueues[lv] = append(s.interruptQueues[lv], t)
s.stats.Enqueued++
}
// installImmediateLocked 把 t 放进 immediate 槽(**纯机制**,不比较优先级)。
//
// "该不该由 t 占槽"是策略,由调度器判定(见 scheduler.go 的 setImmediateLocked)。
// 这里只做:把现任降级入队 → 新任务占槽。
//
// 返回被顶掉的旧持有者(可能为 nil),调用方据此保持不变量的可观测性。
func (s *scheduler) installImmediateLocked(t *Task) *Task {
var demoted *Task
if s.immediate != nil {
// 被顶掉的抢占者降级入队,**不丢**(不变量 I5)。
demoted = s.immediate
s.enqueueInterruptLocked(demoted)
}
s.allocateIDLocked(t)
s.stats.Enqueued++
s.immediate = t
return demoted
}
// ── 中断登记(抢占或入队)──
// registerInterrupt 是登记中断的公共实现(任务已带好 Class/Level)。
//
// 返回 true 表示**真的占住了 immediate 槽**(会真抢占),调用方据此决定
// 是否取消可取消的步骤(如当前 LLM 流式请求)。
// requestPreempt 登记一次**外部**(插件/用户)中断请求,级别由来源声明。
// requestKernelPreempt 是**内核**中断入口(panic / 内核事件 / 上下文超页)。
//
// 级别固定 L4,且**不夹取**——这是 L4 的唯一来源,插件永远够不到。
// ── 让位协议 ──
// preemptGrantedFor 报告是否有已判定的让位请求(安全点据此决定是否挂起)。
func (s *scheduler) preemptGrantedFor() bool {
s.mu.Lock()
defer s.mu.Unlock()
if !s.preemptArmed || s.running == nil {
return false
}
return s.preemptLevel > effectiveLevel(s.running)
}
// clearPreempt 清除让位信号(抢占者已开始执行)。
func (s *scheduler) clearPreempt() {
s.mu.Lock()
s.preemptArmed = false
s.preemptLevel = 0
s.mu.Unlock()
}
// rearmPending 重新武装一个"被拦成入队"的中断。
//
// 临界区(或抢占冷却期)内到达的中断会被拦成入队;一旦离开那个窗口,
// 必须在这里重新求值——否则它只能等当前任务自然结束(设计 §4.3/§5.2)。
// ── 现场保护 ──
// suspend 保存现场压栈。
func (s *scheduler) suspend(t *Task, f *TaskFrame) {
s.mu.Lock()
defer s.mu.Unlock()
if len(s.suspendStack) >= s.maxInterruptFrames {
s.stats.Rejected++
}
s.suspendStack = append(s.suspendStack, &suspendedTask{Task: t, Frame: f})
s.stats.Suspended++
// 饥饿防护:抢占计数 +1(提升有效级)并记录冷却起点。
t.PreemptCount++
t.LastPreemptAt = time.Now()
if s.running == t {
s.running = nil
}
// D1=B:中断任务在上一个任务之前的完整状态上开始运行,
// 因此这里**不**把被打断任务的任何内容交给它。
s.preemptArmed = false
s.preemptLevel = 0
// 栈状态已变化 → 通告调度器(钩子在持锁下调用,只做记录+唤醒)。
s.notifyStackLocked()
}
// popSuspendLocked 弹出中断栈顶并交出现场(调用方须持锁且确认非空)。
//
// ⚠️ 本函数**只应由中断管理器内部调用**:中断栈的操作者只有中断管理器。
// 调度器不得直接弹栈——它只能查询栈状态(suspendTopLocked 等),
// 然后**请求**中断管理器执行恢复(见 restoreTopLocked)。
//
// 弹栈是**机制**;"该不该弹、弹出来给谁跑"是策略(在调度器)。
// 中断管理器不判断优先级,只负责把栈顶原样交出去。
func (s *scheduler) popSuspendLocked() *suspendedTask {
n := len(s.suspendStack)
if n == 0 {
return nil
}
top := s.suspendStack[n-1]
s.suspendStack = s.suspendStack[:n-1]
// Resumed 不在这里计:只有真正恢复执行时(resumeTask)才算一次,
// 否则"排空后 Suspended == Resumed"这条不变量会因弹栈而虚高。
s.notifyStackLocked()
return top
}
// notifyStackLocked 触发栈状态钩子(调用方须持锁)。
//
// 钩子的语义是"通告",不是"决策":中断管理器不知道也不关心调度器会怎么做,
// 它只说"栈变了"。这正是两者解耦的关键——将来换掉调度器的选取策略,
// 或给中断子系统加向量表/多栈,都不需要改对方。
func (s *scheduler) notifyStackLocked() {
if s.stackNotify != nil {
s.stackNotify()
}
}
// restoreTopLocked 执行一次"恢复栈顶"(**中断管理器是唯一操作者**)。
//
// 调度器只做决策("现在该恢复栈顶"),具体弹栈由这里完成——
// 这是"中断栈的操作者只有中断管理器"这条不变量的落点。
//
// 返回被恢复的现场;栈空时返回 nil。
func (s *scheduler) restoreTopLocked() *suspendedTask {
top := s.popSuspendLocked()
if top == nil {
return nil
}
s.running = top.Task
return top
}
// ── 栈状态查询(调度器据此决策;**不提供任何操作栈的方法**)──
// stackDepthLocked 返回中断栈深度。
func (s *scheduler) stackDepthLocked() int { return len(s.suspendStack) }
// stackTopLocked 返回栈顶现场的**只读快照**(Task + Frame 指针)。
//
// 只读:调度器拿它做优先级比较,但**不能**用它弹栈——弹栈只能由
// 中断管理器在 restoreTopLocked 里做。这样"中断栈的操作者只有中断管理器"
// 这条不变量在类型层面就无法被绕过。
func (s *scheduler) stackTopLocked() *suspendedTask {
n := len(s.suspendStack)
if n == 0 {
return nil
}
return s.suspendStack[n-1]
}
// canSuspend 报告中断栈是否还能压入新帧。
func (s *scheduler) canSuspend() bool {
s.mu.Lock()
defer s.mu.Unlock()
return len(s.suspendStack) < s.maxInterruptFrames
}
// suspendDepth 返回当前中断栈深度(仅用于日志)。
//
// 单独一个方法而不是让调用方直接读 s.suspendStack:那个字段只允许在 s.mu 下访问。
func (s *scheduler) suspendDepth() int {
s.mu.Lock()
defer s.mu.Unlock()
return len(s.suspendStack)
}
// nextInterruptSelection 是**恢复决策**:中断侧该出谁。
//
// 顺序(这是"谁先跑"的唯一实现处):
//
// ① immediate —— 刚抢占成功的,必须立即生效
// ② 最高级待处理中断 vs 中断栈顶,取高者(持平归栈顶,维持 LIFO 与公平)
//
// 返回 ok=false 表示中断侧没有可出的任务,调用方(nextRef)应转去就绪队列。
//
// 为什么恢复决策在这里而不是调度器:见文件头「为什么恢复决策在这里」。