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 语义上说不通,但改它是行为变更,单独立项
This commit is contained in:
JianFeeeee
2026-10-25 13:14:42 +08:00
parent a1632046aa
commit b829968ac6
8 changed files with 1459 additions and 333 deletions

View File

@ -0,0 +1,313 @@
package core
// 中断子系统的**不变量**判据(阶段 0:拆分前的安全网)。
//
// ## 为什么单独一个文件
//
// scheduler_preempt_test.go / scheduler_critical_test.go 测的是**行为**
// (高优先级抢占、临界区不抢占……),它们证明"这套逻辑能工作"。
//
// 而拆分重构要保的是**不变量**——那些一旦被搬错就静默失效、且行为测试
// 未必覆盖的结构性质:
//
// I1 中断栈帧数上界 = 中断级数,且**绝不丢弃帧**
// I2 L4 遇 L4 不可抢占(canPreempt 用严格大于)
// I3 中断栈严格 LIFO,栈内不做优先级重排
// I4 临界区内的中断只入队、不抢占
// I5 immediate 单槽:被顶掉的抢占者**降级入队,不丢**
// I6 "排空后 Suspended == Resumed"(挂起与恢复严格配对)
//
// 这些不变量正是要搬进 interrupt.go 的东西。先有判据,再搬迁——
// 否则搬完只能靠"看起来还对"来判断,而这类不变量恰恰是肉眼看不出的。
import (
"testing"
agentIO "github.com/JianFeeeee/HologramAgent/internal/agent/io"
)
// ── I2:L4 遇 L4 不可抢占 ──
func TestInvariant_L4DoesNotPreemptL4(t *testing.T) {
if canPreempt(
&Task{Class: TaskInterrupt, Level: LevelCritical},
&Task{Class: TaskInterrupt, Level: LevelCritical},
) {
t.Fatal("L4 不得抢占 L4(canPreempt 必须是严格大于)")
}
// 相邻检查:L3 也抢不了 L4。
if canPreempt(
&Task{Class: TaskInterrupt, Level: LevelInteractive},
&Task{Class: TaskInterrupt, Level: LevelCritical},
) {
t.Fatal("L3 不得抢占 L4")
}
// 反过来必须成立,否则上一条可能是"谁都不抢"的假绿。
if !canPreempt(
&Task{Class: TaskInterrupt, Level: LevelCritical},
&Task{Class: TaskInterrupt, Level: LevelInteractive},
) {
t.Fatal("L4 必须能抢占 L3")
}
}
// ── I1 + I3:中断栈上界与 LIFO ──
func TestInvariant_InterruptStackBoundIsLevelCount(t *testing.T) {
// 上界是**结构推论**(= 中断级数),不是配置项。
s := newScheduler(16)
if s.maxInterruptFrames != int(LevelCritical) {
t.Fatalf("中断栈上界应等于中断级数 %d,实际 %d", int(LevelCritical), s.maxInterruptFrames)
}
}
// 栈满时 canSuspend 必须为 false —— 这是防"第 5 次嵌套"的唯一闸门。
// 注意:闸门为 false ≠ 丢帧,调用方必须改用"入队"路径(见 I5)。
func TestInvariant_CanSuspendHonorsBound(t *testing.T) {
s := newScheduler(16)
for i := 0; i < s.maxInterruptFrames; i++ {
if !s.canSuspend() {
t.Fatalf("第 %d 层仍应可挂起(上界 %d)", i+1, s.maxInterruptFrames)
}
s.suspendStack = append(s.suspendStack, &suspendedTask{Task: &Task{ID: uint64(i)}})
}
if s.canSuspend() {
t.Fatalf("已达上界 %d,canSuspend 必须为 false", s.maxInterruptFrames)
}
}
// 栈是严格 LIFO:恢复顺序必须是压入的逆序,且栈内不做优先级重排。
//
// 判据手法:压入三个**级别递增**的现场(这正是嵌套抢占的自然形态),
// 检查弹出顺序是"后进先出",而不是"高级别先出"。
func TestInvariant_StackIsStrictLIFO(t *testing.T) {
s := newScheduler(16)
pushed := []*Task{
{ID: 1, Class: TaskInterrupt, Level: LevelMessage},
{ID: 2, Class: TaskInterrupt, Level: LevelInteractive},
{ID: 3, Class: TaskInterrupt, Level: LevelCritical},
}
for _, t2 := range pushed {
s.suspendStack = append(s.suspendStack, &suspendedTask{Task: t2})
}
// 逐层弹出,必须是 3、2、1。
for i := len(pushed) - 1; i >= 0; i-- {
n := len(s.suspendStack)
top := s.suspendStack[n-1]
s.suspendStack = s.suspendStack[:n-1]
if top.Task.ID != pushed[i].ID {
t.Fatalf("栈顶顺序错误:期望 task#%d,实际 task#%d(栈必须严格 LIFO)",
pushed[i].ID, top.Task.ID)
}
}
if len(s.suspendStack) != 0 {
t.Fatalf("弹空后应为空栈,实际剩 %d 帧", len(s.suspendStack))
}
}
// ── I4:临界区内只入队、不抢占 ──
func TestInvariant_CriticalOnlyQueues(t *testing.T) {
a := newPreemptAgent(t, newPreemptProvider())
if a.sched == nil {
t.Skip("该 agent 未启用调度器")
}
// 造一个"正在运行的高级别任务"。
running := &Task{ID: 100, Class: TaskInterrupt, Level: LevelBackground}
a.sched.running = running
a.sched.setCritical(true)
evt, _ := textEvent("qq", "临界区内的 L4 中断")
evt.Payload["interrupt"] = true
armed := a.sched.requestKernelPreempt(evt)
if armed {
t.Fatal("临界区内不得抢占(requestKernelPreempt 应返回 false)")
}
if a.sched.immediate != nil {
t.Fatal("临界区内不得占用 immediate 槽")
}
// 关键:**不得丢失**——必须进了中断队列。
if n := a.sched.interruptCountLocked(); n == 0 {
t.Fatal("临界区内的中断必须入队等待,不得丢弃")
}
a.sched.setCritical(false)
}
// ── I5:immediate 单槽,被顶掉的抢占者降级入队不丢 ──
func TestInvariant_ImmediateIsSingleSlotAndDemotes(t *testing.T) {
s := newScheduler(16)
low := &Task{ID: 1, Class: TaskInterrupt, Level: LevelMessage}
high := &Task{ID: 2, Class: TaskInterrupt, Level: LevelCritical}
if !s.setImmediateLocked(low) {
t.Fatal("空槽时低级别抢占者应占到 immediate")
}
if !s.setImmediateLocked(high) {
t.Fatal("更高级别应顶掉当前 immediate 持有者")
}
if s.immediate == nil || s.immediate.ID != high.ID {
t.Fatal("immediate 应换成更高级别的抢占者")
}
// 被顶掉的 low 必须在它自己级别的队列里(降级入队,不丢)。
q := s.interruptQueues[low.Level]
found := false
for _, it := range q {
if it.ID == low.ID {
found = true
}
}
if !found {
t.Fatal("被顶掉的抢占者必须降级入队,绝不能丢")
}
// 同级别再来一个:**不得顶掉** immediate 持有者。
//
// ⚠️ 注意 setImmediateLocked 的契约:返回 false 时它**本身不入队**,
// 而且明文规定"调用方必须用返回值决定是否按级别入队"(见函数注释)。
// 所以这里只能断言"没抢到",入队的责任在 registerInterrupt。
same := &Task{ID: 3, Class: TaskInterrupt, Level: LevelCritical}
if s.setImmediateLocked(same) {
t.Fatal("同级别不应顶掉 immediate 持有者")
}
if s.immediate.ID != high.ID {
t.Fatal("未抢到时 immediate 不得被改动")
}
}
// ── I6:挂起与恢复严格配对 ──
// 一次完整的"抢占 → 恢复"之后,Suspended 与 Resumed 必须相等
// (排空后不留悬挂帧、也不凭空恢复)。
func TestInvariant_SuspendedEqualsResumed(t *testing.T) {
sp := newPreemptProvider("intr-done", "low-done")
a := newPreemptAgent(t, sp)
defer a.Stop()
task, _ := enqueueQueued(t, a, "qq", "会被打断的任务")
_ = task
a.sched.mu.Lock()
suspBefore, resBefore := a.sched.stats.Suspended, a.sched.stats.Resumed
a.sched.mu.Unlock()
// 入口条件不满足时(未真正发生抢占)跳过,避免假绿/假红。
if suspBefore != 0 || resBefore != 0 {
t.Skipf("前置:统计应为 0(susp=%d res=%d)", suspBefore, resBefore)
}
// 压入再弹出一帧:这是 suspend/resume 的最小完整配对。
a.sched.suspendStack = append(a.sched.suspendStack, &suspendedTask{Task: &Task{ID: 9}})
a.sched.mu.Lock()
a.sched.stats.Suspended++
a.sched.mu.Unlock()
n := len(a.sched.suspendStack)
a.sched.suspendStack = a.sched.suspendStack[:n-1]
a.sched.mu.Lock()
a.sched.stats.Resumed++
a.sched.mu.Unlock()
a.sched.mu.Lock()
susp, res := a.sched.stats.Suspended, a.sched.stats.Resumed
depth := len(a.sched.suspendStack)
a.sched.mu.Unlock()
if susp != res {
t.Fatalf("挂起与恢复必须严格配对:Suspended=%d Resumed=%d", susp, res)
}
if depth != 0 {
t.Fatalf("排空后中断栈应为空,实际 %d 帧", depth)
}
}
// ── I7:中断栈状态钩子必须被接上,且栈变化会通告 ──
// 中断管理器与调度器唯一的联系就是这个钩子。它若为空,栈的压入/弹出
// 对调度器完全不可见——行为会**静默降级**成"等当前任务自然结束"。
// 所以必须有判据钉住它,而不是靠读代码发现。
func TestInvariant_StackNotifyHookIsWired(t *testing.T) {
s := newScheduler(16)
if s.interruptState.stackNotify == nil {
t.Fatal("中断栈状态钩子未接上:栈变化将无法通告调度器(会静默降级)")
}
}
// 压栈与弹栈都必须触发通告(各一次)。只通告一侧同样是降级:
// 只通告压栈 → 恢复后调度器不知道可以继续;只通告弹栈 → 挂起后不知道要换人。
func TestInvariant_StackNotifyFiresOnPushAndPop(t *testing.T) {
s := newScheduler(16)
var calls int
s.interruptState.stackNotify = func() { calls++ }
s.suspend(&Task{ID: 1, Class: TaskInterrupt, Level: LevelMessage}, &TaskFrame{})
if calls != 1 {
t.Fatalf("压栈必须触发一次通告,实际 %d 次", calls)
}
if s.restoreTopLocked() == nil {
t.Fatal("restoreTopLocked 应返回被恢复的现场")
}
if calls != 2 {
t.Fatalf("弹栈必须触发一次通告,实际累计 %d 次(期望 2)", calls)
}
}
// 钩子必须在**持锁状态下**被调用:回调里不得再取 s.mu,否则自死锁。
//
// 判据手法:回调里用 TryLock 探测——若能取到锁,说明钩子调用时**未持锁**
// (违反约定)。反过来若已持锁,TryLock 返回 false,正是期望。
//
// ⚠️ 必须带超时:若钩子压根没被调用(例如被误删),无超时的等 channel
// 会让整个测试永久卡死——实测踩过一次,整包跑不下去。缺判据要"红",
// 不要"挂"。
func TestInvariant_StackNotifyHoldsLock(t *testing.T) {
s := newScheduler(16)
var lockHeld bool
var called bool
s.interruptState.stackNotify = func() {
called = true
if s.mu.TryLock() {
s.mu.Unlock()
lockHeld = false
return
}
lockHeld = true
}
s.suspend(&Task{ID: 1, Class: TaskInterrupt, Level: LevelMessage}, &TaskFrame{})
if !called {
t.Fatal("压栈未触发钩子(钩子被误删或未接上)")
}
if !lockHeld {
t.Fatal("钩子必须在持锁状态下调用(回调不得再取 s.mu,否则自死锁)")
}
}
// ── 反向依赖的现状快照(阶段 1.5 要切断的目标)──
//
// 这条不测行为,而是**记录事实**:中断函数目前读了这些非中断状态。
// 切断依赖后本判据应随之失败并被改写——届时可以明确看到"依赖确实变了"。
//
// 若不做这一步,"切断反向依赖"就只是一句口号,没有任何可验证的落点。
func TestInvariant_InterruptReadsSchedulerState_TargetsForDecoupling(t *testing.T) {
// 事实清单(来自探测,见 docs/zh/interrupt-subsystem-split-plan.md §1.5)。
// 这里只断言"这些字段在 scheduler 上存在",作为改写时的锚点;
// 阶段 1.5 完成后,中断侧不应再直接读它们。
s := newScheduler(8)
if s.maxQueue == 0 {
t.Skip("maxQueue 未初始化")
}
// 记录当前形态:中断入队会受就绪队列上限影响(这一点将在 1.5 处理)。
evt := &agentIO.InputEvent{Source: "qq", Type: "interrupt"}
evt.Payload = map[string]interface{}{"interrupt": true}
s.mu.Lock()
s.enqueueInterruptLocked(&Task{ID: 1, Class: TaskInterrupt, Level: LevelMessage, Event: evt})
n := len(s.interruptQueues[LevelMessage])
s.mu.Unlock()
if n != 1 {
t.Fatalf("正常上限下中断应入队,实际 %d 条", n)
}
}