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,534 @@
# 中断子系统独立化 —— 探测结论与实施计划
> 状态:**计划**(未实施)
> 起因:中断语义散落在 `scheduler.go` 一个 1476 行的文件里,且入口横跨 5 个文件。
> 修改任何一条中断规则(级别比较、抢占条件、栈深度)都要在多处同步改,漏一处的后果是静默的。
---
## 一、探测结论(实测,非推演)
### 1.1 现状:中断没有独立子系统
`type scheduler struct`(`scheduler.go:330`)一个结构体同时承担四件事:
| 职责 | 字段 | 归属应为 |
| --- | --- | --- |
| 就绪队列调度 | `queue` / `running` / `maxQueue` / `backpressured` | 调度器 |
| 中断分级排队 | `interruptQueues[5]` | **中断子系统** |
| 抢占与让位 | `immediate` / `preemptArmed` / `preemptLevel` / `critical` | **中断子系统** |
| 中断栈(嵌套挂起) | `suspendStack` / `maxInterruptFrames` | **中断子系统** |
| 用户停止配额 | `cancelBudget` / `stopArmed` | **中断子系统**(是"打断"的一种) |
### 1.2 中断逻辑的实际分布
| 文件 | 中断相关职责 | 行数量级 |
| --- | --- | --- |
| `scheduler.go` | 队列/抢占/栈/让位/级别语义(**主体**) | 1476 行中约 205 处引用 |
| `eventloop.go` | 中断注入路径 | 少量 |
| `overflow.go` | `raiseKernelInterrupt` 的一种来源 | 1 处 |
| `resident.go` | 子 contextfull → 父 L4 | 1 处 |
| `task.go` | 安全点让位判定 | 1 处 |
### 1.3 最能说明问题的证据
`scheduler.go:374` 那段注释:
> 链条 = 排队(L0) ← I(L1) ← I(L2) ← I(L3) ← I(L4 运行中),被挂起 4 帧
这是**中断子系统的核心不变量**(`maxInterruptFrames = int(LevelCritical)`,结构推论、非配置项)。
它现在藏在 1476 行文件的中间,没有任何类型或文件专门承载它。改中断语义的人必须
先读完整份 `scheduler.go` 才能确认自己没漏。
### 1.4 关键有利条件(决定拆分方案)
| 探测项 | 结果 | 含义 |
| --- | --- | --- |
| 中断状态字段是否被跨文件直接访问 | **零**(`interruptQueues`/`immediate`/`suspendStack`/`preemptArmed`/`cancelBudget`/`stopArmed` 全部只在 `scheduler.go` 内读写) | 拆分可用**同包拆文件**方式,无需改包边界、无循环依赖风险 |
| `Task` / `Level` / `TaskClass` / `SchedulerStats` 定义位置 | `scheduler.go`(同包) | 可自由移动 |
| `TaskFrame` 定义位置 | `task.go`(同包) | 不需移动,同包可见 |
| 外部调用面(非 scheduler.go) | `raiseKernelInterrupt` 3 处、其余各 1~2 处,共约 15 处 | 面很小,改签名代价可控 |
| 涉及调度器/中断的测试文件 | 16 个 | 安全网足够 |
**结论**:采用**同包拆文件**(`package core` 内新增 `interrupt.go` 等),不新建子包。
理由:状态字段零跨文件访问 ⇒ 不需要为了封装而改包边界;而同包拆分零循环依赖风险、
零 API 破坏,是纯结构搬迁。
---
## 一点五、按 OS 中断语义重新界定边界(2026-10-25 用户裁定)
> 中断类似操作系统中的中断语义:**只提供中断优先级、中断向量、现场保护能力**。
> 调度器建立在中断和其他子系统提供的**基础**之上。
这把尺子把「中断」与「调度」切得很干净,也让计划里两处归属**必须修正**:
| 能力 | 归属 | 判据 |
| --- | --- | --- |
| 优先级(Level 定义/比较/clamp) | **中断** | 谁打断谁的唯一依据 |
| 中断向量(哪些待处理、按级别排队、immediate 槽) | **中断** | 待处理集合本身 |
| 现场保护(挂起现场、栈、LIFO、帧数上界) | **中断** | 被中断者的上下文保全 |
| 中断屏蔽(critical 临界区) | **中断** | 对应 CPU 的关中断 |
| 「下一个跑谁」的决策 | **调度器** | 建立在向量之上的选择 |
| 就绪队列、队列配额、转投、背压 | **调度器** | 与"打断"无关 |
| 任务 ID 分配、统计计数 | **调度器/观测** | 不是中断能力 |
### 🔴 两处必须修正的归属(原计划写错了)
**① 停止配额(`armStop`/`takeStop`/`consumeCancel`)→ 归调度器,不归中断**
原计划默认归中断,理由是"它也是打断"。按裁定这是错的:
```go
queued := len(s.queue) + pending // ← 操作对象是**就绪队列**
if queued > s.cancelBudget { s.cancelBudget = queued }
```
它计算的是**就绪队列的长度**,是队列控制("这批已排队的消息别再跑了"),
既不参与级别比较、也不进中断向量、更不压中断栈。停止**不是**一次中断,
它是调度器对**已排队任务**的一次批量取消。
**② `critical` 的定位要拆开看**
- `critical` 的**语义**("运行任务处于不可打断状态")属于中断 → 对应关中断
- `setCritical` 的**调用点**在任务生命周期里(`task.go:316/408`)→ 由调度器调用
所以它落在中断子系统的状态里、由调度器设置,与 OS 的 `cli/sti` 完全同构。
### 🔴 中断函数目前反向依赖了调度器状态(拆分时要一并处理)
实测三个中断核心函数读了这些**非中断**字段:
```
registerInterrupt → s.running s.stats s.signalWake
enqueueInterruptLocked → s.maxQueue s.allocateIDLocked s.stats
setImmediateLocked → s.allocateIDLocked s.stats
```
按裁定这些都不该由中断子系统拥有:
| 反向依赖 | 正确做法 |
| --- | --- |
| `s.running`(读当前运行任务做级别比较) | 中断只接受**调用方传入**的 running;由调度器注入 |
| `s.stats`(统计) | 统计归观测,中断只发事件/回调 |
| `s.maxQueue`(就绪队列上限) | 中断队列**不该**受就绪队列上限约束;若确有上限应自有常量 |
| `s.allocateIDLocked`(任务 ID) | ID 归任务创建方(调度器),中断只接收入队 |
| `s.signalWake` | 唤醒调度器是**调度器的事**;中断登记完由调度器决定是否唤醒 |
**这说明当前"中断"与"调度"是双向耦合的**,不是单向的"调度建立在中断之上"。
拆分阶段 1 若只搬代码不动这些依赖,只是把耦合搬了个位置。因此计划新增
**阶段 1.5:切断反向依赖**。
---
## 一点六、恢复归属的精确切分(2026-10-25 用户补充裁定)
> 中断子系统就是只负责中断部分:按优先级打断、被打断的任务压栈,这些由中断子系统负责。
> **任务恢复也是中断子系统负责**。调度器是在中断子系统的基础上构筑的。
这条补充纠正了我计划的一处遗漏:原计划把恢复整体留在了调度器侧。实际上
**"恢复"有两个层面,必须分开**:
| 层面 | 内容 | 归属 | 判据 |
| --- | --- | --- | --- |
| **恢复决策** | 该恢复谁:比较「栈顶 vs 最高级待处理中断」,取高者;弹栈;产出 `(task, frame, nextSuspended)` | **中断子系统** | 这是"现场保护的逆操作"——压进去的谁、什么时候取出来,属于同一个语义整体 |
| **恢复执行** | `resumeTask`:`rebaseFramePrefix` 重建前缀 → `runTaskSteps` 跑 → `finishInputTask` → `done` | **调度器** | 这是在**执行一个任务**,与"打断/保全"无关 |
**决定性的论据**:栈的压入(`suspend`)在中断侧,若弹出与选择留在调度器侧,
那么「LIFO 语义 + 栈顶与中断队列的优先级比较」这条规则就被劈成两半——
改中断语义时又要跨两个子系统改,正是本次重构要消灭的形态。
### 因此 `nextRef` 也要按此切分
`nextRef` 目前是单个函数,它做四件事:
```go
if s.immediate != nil { ... } // ① 中断侧:immediate 优先
qTask, qLevel := s.highestInterruptLocked() // ② 中断侧:最高级待处理
if n := len(s.suspendStack); n > 0 { ... } // ③ 中断侧:栈顶 vs ②取高者
if len(s.queue) > 0 { ... } // ④ 调度侧:就绪队列
```
①②③ 全是中断语义,④ 才是调度。切法:
- 中断子系统暴露 `nextInterruptSelection() (task, frame, sel, ok)`
—— 内部按 ①②③ 的顺序决策,返回"该中断侧出谁"或"中断侧无货"
- `nextRef` 变成:先问中断侧;中断侧无货再取就绪队列
这样"谁先跑"的**优先级规则只有一处**(中断子系统内部),调度器只负责
"中断侧没货时取就绪队列"。
---
## 一点七、中断向量系统(本次拆分的**真正目的**)
> 独立成中断子模块就是为了方便添加中断向量系统。
### 7.1 先纠正一处术语误用
我此前把 `interruptQueues` 称作"中断向量",**错了**。两者是不同层面的东西:
| 概念 | OS 语义 | 本仓现状 |
| --- | --- | --- |
| **中断向量** | 中断号 → 处理程序入口的映射(IDT) | **不存在** |
| **中断队列** | 待处理中断的排队 | `interruptQueues[5][]*Task` |
本仓之所以一直不需要向量表:中断任务本身就是 `*Task`,入队时已带执行体
(`Task.Kind` 决定走哪条执行路径),不需要查表找入口。
但代价是**"有哪些中断、各自什么语义"散落在调用点**,见下。
### 7.2 现状:中断源是三个裸调用点,没有统一登记
实测全部 L4 来源都走同一个函数、同一套参数形状:
```go
raiseKernelInterrupt(source, channel, text) // 级别固定 L4,不夹取
├─ reportTaskPanic → ("kernel", "kernel", "任务 #N panic…")
├─ raiseContextOverflow → ("kernel/overflow", "kernel", "上下文超页…")
└─ handleChildContextFull→("child/"+rc.id, "kernel", "驻留子…已满…")
```
**问题是**:这三个来源的差异只体现在 `source` 字符串上,它们的语义契约
(谁会发、级别几、是否需要模型响应、递归保护规则)全靠注释和调用方自律。
举例:`reportTaskPanic` 里那段递归保护是**手写的**:
```go
if t.Class == TaskInterrupt && t.Level >= LevelCritical { return }
// 否则同一个 panic 会自我放大成中断风暴
```
这条规则只属于"panic 中断",却硬编码在 `reportTaskPanic` 里。
新增一种中断源时,没人会记得"要不要也加递归保护"——这就是要向量化的理由。
### 7.3 中断向量表的设计
```go
// 向量编号:稳定的、可被日志与诊断引用的中断源标识。
type VectorID string
const (
VectorTaskPanic VectorID = "task_panic" // 任务 panic
VectorContextOverflow VectorID = "ctx_overflow" // 上下文超页
VectorChildContextFull VectorID = "child_ctxfull" // 驻留子上下文已满
// 预留:未来的内核事件源在此登记
)
// interruptVector 是一个中断源的完整契约。
type interruptVector struct {
ID VectorID
Level Level // 该源的中断级别(L4 源固定 LevelCritical)
// Build 由源自己构造中断载荷;向量表统一负责登记与让位。
Build func(a *Agent, arg any) (text string, ok bool)
// Guard 在登记前做前置判定(如 panic 的递归保护)。
// 返回 false 表示本次不产生中断。
Guard func(a *Agent, t *Task) bool
// ExpectsReply 声明本轮是否期望模型产生回复。
// false 表示"纯认知输入"——见 §7.5。
ExpectsReply bool
}
```
向量表本身:
```go
var interruptVectors = map[VectorID]interruptVector{
VectorTaskPanic: {...},
VectorContextOverflow: {...},
VectorChildContextFull:{...},
}
```
统一入口:
```go
func (a *Agent) raiseVector(id VectorID, arg any) bool
```
### 7.4 收益(可验证的,不是口号)
| 收益 | 现状 | 向量化后 |
| --- | --- | --- |
| 新增一种内核中断 | 找 `raiseKernelInterrupt` 调用点照抄,容易漏规则 | 在表里加一条,规则随条目声明 |
| 递归保护 | 硬编码在 `reportTaskPanic` | 是 `Guard`,只对 panic 条目生效 |
| 级别来源 | 分散在 `interruptLevel`/`parseInterruptLevel`/`isKernelLevelSource` 三段 | 内核源由向量条目声明;插件源仍走原三段(那是外部输入,必须夹取) |
| 诊断 | source 字符串,无稳定标识 | `VectorID` 可被日志/状态面引用 |
| "哪些中断存在" | 全文搜索 `raiseKernelInterrupt` | 读向量表一处 |
### 7.5 附带解决:`ExpectsReply` 与"内核提示引起模型回复"
用户报过一个现象:内核的上下文超页提示会引发模型一整轮回复
("主人,这条只是内核提示……")。
根因链:超页 → `raiseContextOverflow` → L4 → 抢占 → **新任务跑完整推理** →
`finishInputTask` 无条件 `emitResponse`。
向量表里的 `ExpectsReply` 字段让"这条中断要不要产生回复"成为**声明**,
而不是靠提示词约定(现状 `tooldefs.go:218` 要求"必须认真处理"、
`offload.go:94` 又写"无需回复",两套约定并存)。
> ⚠️ 注意:这一步会触及 `finishInputTask` 的 `emitResponse` 分支,
> 属于**行为变更**,必须与拆分分开做、单独验证。**本次拆分阶段不实施**,
> 只是把字段预留在类型里(默认 `ExpectsReply: true`,行为不变)。
### 7.6 实施顺序(在拆分之后)
中断向量系统是**拆分的目的,但不是拆分的第一步**。顺序必须是:
1. 先拆(阶段 0~3)—— 纯结构,行为零变化
2. 再引入向量表 —— 把三个调用点改成查表,**行为仍零变化**
3. 最后才做 §7.5 的 `ExpectsReply` 行为变更 —— 单独提交、单独验证
理由:如果拆文件和改语义同时做,一旦行为出问题,无法判断是搬错了还是改错了。
---
## 二、目标结构
```
internal/agent/core/
├── interrupt.go ← 新增:中断子系统(语义 + 状态 + 全部操作)
├── scheduler.go ← 瘦身:只留就绪队列、任务生命周期、调度循环
├── interrupt_level.go ← 新增:Level 定义、比较、解析(可合进 interrupt.go)
└── interrupt_test.go ← 新增:中断子系统自身的判据(现有测试保持不动)
```
### 2.1 中断子系统(`interrupt.go`)承载什么
**类型与不变量**
- `Level`(L1..L4)、`TaskClass`(就绪/中断/self)
- `effectiveLevel` / `canPreempt` / `clampPluginLevel`(级别语义**唯一实现**)
- `interruptLevel` / `parseInterruptLevel` / `isKernelLevelSource`(外部信号 → 级别)
- `maxInterruptFrames` 的推导与注释(核心不变量,必须有专门的落点)
**状态**(按 OS 语义,只留与"打断/保全"直接相关的)
- `interruptQueues [5][]*Task` —— 中断向量(按优先级分组)
- `immediate *Task` —— 已判定的最高优先待运行者(而非"抢来的槽")
- `suspendStack []*suspendedTask` + `suspendedTask` —— 现场保护
- `preemptArmed` / `preemptLevel` —— 让位信号("有一个更高优先级已判定,请在安全点让位")
- `critical` —— 中断屏蔽
**明确排除**(归调度器):`cancelBudget` / `stopArmed` / `stats` / `maxQueue` / 任务 ID 分配
**操作**
- 入队与抢占:`enqueueInterruptLocked` / `setImmediateLocked` / `registerInterrupt` /
`requestPreempt` / `requestKernelPreempt`
- 选择与弹出:`highestInterruptLocked` / `popInterruptLocked` / `interruptCountLocked`
- 让位协议:`preemptGrantedFor` / `clearPreempt` / `rearmPending` / `canSuspend`
- 中断栈:`suspend` / `suspendDepth`(**压入**)
- **恢复决策**:`nextInterruptSelection()`(含栈顶弹出、栈顶 vs 中断队列比较)
- 唯一入口:`raiseKernelInterrupt`(内核侧 L4)
**不包含**:停止配额(`armStop`/`takeStop`/`consumeCancel`)→ 归调度器
### 2.2 调度器(`scheduler.go`)保留什么
- 就绪队列:`queue` / `maxQueue` / `enqueue` / `next` / `queueLen` / `queueSnapshot`
- 任务生命周期:`done` / `removeTask` / `allocateIDLocked` / `runningTask` / `runningFor`
- 转投:`takeQueuedInputs` / `requeueFront`
- 后台压力:`hasRoom` / `noteBackpressure` / `clearBackpressure`
- **停止配额**:`armStop` / `takeStop` / `consumeCancel`(操作就绪队列,不是中断)
- **任务 ID 分配**:`allocateIDLocked`
- **统计**:`stats`(`bumpInterruptLevel` 由调度器在登记中断后调用)
- 调度循环:`schedulerLoop` / `nextRef` / `pumpInbox` / `waitSchedulerStopped`
- **恢复执行**:`resumeTask`(重建前缀 + 跑任务 + 收尾;它不问"该恢复谁",
只按中断子系统给出的选择执行)
- 快照:`DumpScheduler` / `schedulerStatus`
> `nextRef` 是**两者的交界**:它按优先级从 immediate → 中断队列 → 中断栈顶 → 就绪队列
> 取任务。放在 `scheduler.go`(因为它是"下一个执行谁"的决策),但它调用的
> 中断侧查询函数归 `interrupt.go`。
---
## 二点五、实施进展(2026-10-25)
| 阶段 | 状态 | 说明 |
| --- | --- | --- |
| 0 安全网 | ✅ 完成 | `interrupt_invariant_test.go`:I1–I7 共 11 条判据,两条经变异验证确实变红 |
| 1 纯搬迁 | ✅ 完成 | 中断机制迁入 `interrupt.go`(303 行),`scheduler.go` 从 1476 → 1369 行 |
| 1.5 切断反向依赖 | ⏳ 部分 | 已建立**中断栈状态钩子**(`stackNotify`)实现单向通告;`maxQueue` 仍约束中断队列(见下) |
| 2 结构收敛 | ✅ 完成 | 划界落实:机制在 `interrupt.go`,策略在 `scheduler.go` |
| 3 验证 | ✅ 完成 | 全仓 `go test -p 1 ./...` 通过;`-race` 三关键包绿;变异验证通过 |
| 中断向量表 | ⏳ 未做 | §7 已设计,按 §7.6 顺序应在拆分完成后单独做 |
### 实际划定的边界(比原计划更严格)
用户三次裁定后确定的最终边界:
| 归属 | 内容 |
| --- | --- |
| **中断子系统**(`interrupt.go`) | 中断队列、immediate 槽、**中断栈(唯一操作者)**、中断屏蔽(critical)、让位信号原语、**栈状态钩子** |
| **调度器**(`scheduler.go`) | 任务优先级语义(`Level`/`effectiveLevel`/`canPreempt`/`clampPluginLevel`)、**恢复决策**(`nextInterruptSelection`)、就绪队列、停止配额、背压 |
关键设计:**中断栈只有中断管理器能操作**。调度器通过 `stackTopLocked()`
**只读查询**栈顶做优先级比较,真正的弹栈由 `restoreTopLocked()` 在中断管理器内完成——
这条不变量在类型层面就无法被绕过。
**解耦机制**:`stackNotify` 钩子。中断栈变化时通告调度器,调度器据此唤醒
调度循环重新求值。中断管理器不知道调度器会怎么选,调度器也不碰栈内部——
将来给中断子系统加向量表、换多栈策略,都不必改调度器。
### 顺带修掉的真实竞态(阻塞 CI)
`-race` 实测发现两处**真实数据竞争**(非本次拆分引入,`ab27d1a` 上 CI 已红):
| 位置 | 问题 |
| --- | --- |
| `core/stages.go:withExecuteCtx` | 往调用方共享的 `tc.Arguments` **原地写** ctx |
| `io/channel.go:bindExecCtx` | 同样原地写设备 args |
工具执行在自己的 goroutine 里,超时/取消路径下主 goroutine 仍在读同一份 args。
修法:复制 map 而非原地写。变异验证:退回原地写后 `TestPreempt_DeferredDuringToolExec`
立刻报 `DATA RACE`。
修前 `internal/agent/core` 在 `-race` 下 **30 个测试失败**(整包被判红,掩盖其它真竞争);
修后归零。
### 阶段 1.5 遗留:`maxQueue` 确实约束中断队列
查证结果(回答原计划里的待定项):`enqueueInterruptLocked` 读的是
`s.maxQueue`(**就绪队列**上限),并在超限时丢**最老**的中断并计 `Rejected`。
这在 OS 语义上说不通——中断队列容量不该由运行队列长度决定。
但**去掉它是行为变更**,按"结构重构不改语义"的原则本次保持不变。
建议后续单独立项:给中断队列自有常量(如 `maxInterruptQueue`),
并单独验证背压行为。
---
## 三、实施步骤
### 阶段 0:安全网(先做,不可跳过)
1. 跑一次基线:`go test ./internal/agent/core/ -count=1` 记录结果。
2. 记录**中断相关测试的全量清单**(16 个文件里与 preempt/suspend/interrupt 相关的用例)。
3. 补一组**行为快照判据**,专测中断不变量(现有测试偏功能,不变量覆盖不足):
- 中断栈帧数上界 = `int(LevelCritical)`,且**绝不丢帧**
- L4 遇 L4 不抢占(严格大于)
- 栈内严格 LIFO,不按级别重排
- 临界区内的中断只入队、不抢占
- `effectiveLevel` 对 self/就绪任务的封顶行为
### 阶段 1:纯搬迁(零逻辑修改)
1. 新建 `interrupt.go`,把 §2.1 列出的**类型、字段、方法**原样搬入。
- `scheduler` 的字段搬走后,`interrupt.go` 里需要一个持有者。两个选项见 §四风险 A。
2. `scheduler.go` 只留 §2.2 的内容。
3. **不改任何一行逻辑**。搬完立即跑全量测试,必须与阶段 0 基线逐条一致。
### 阶段 1.5:切断反向依赖(关键,不是可选)
搬迁后立即处理 §1.5 列出的五处反向依赖,让依赖方向变成单向
「调度器 → 中断」:
1. `registerInterrupt` 不再读 `s.running`:改由调度器传入 running 快照。
2. `s.stats` 不再被中断函数直接改:中断返回"发生了什么",由调度器记账。
3. `s.maxQueue` 不再约束中断队列:中断队列上限若需要,用自有常量
(`maxInterruptFrames` 同理,它是结构推论不是配置)。
4. `allocateIDLocked` 移出中断:中断只接收"已带 ID 的任务"。
5. `signalWake` 由调度器在登记后调用,不在中断内部。
> 若判断某处改动会变更行为(例如中断队列原本真的受 `maxQueue` 限制),
> **停下来先确认**,不要为了结构干净而悄悄改语义。
### 阶段 2:结构收敛
1. `maxInterruptFrames` 的推导常量与注释搬到 `interrupt.go` 顶部,作为子系统不变量。
2. `raiseKernelInterrupt` 的三个来源(panic / overflow / child-contextfull)在
`interrupt.go` 里集中登记为注释表格,说明"L4 是统一的抢占/让位机制,
不同功能只是它的不同用途"。
3. 检查是否有函数可降为 unexported(搬迁后可私有化的先私有化)。
### 阶段 3:验证与文档
1. 全量测试 + `-race`。
2. 在 `docs/zh/` 补一节,说明中断子系统的边界与不变量。
3. 更新 `scheduler.go` 顶部注释,指向 `interrupt.go`。
---
## 四、风险与对策
### A. 字段搬迁的持有方式(**主要决策点**)
中断状态必须与调度器共享同一把锁(`s.mu`)——它们在同一临界区里被一起读写。
两种形态:
**A1(推荐):`scheduler` 内嵌中断状态结构体**
```go
type scheduler struct {
mu sync.Mutex
interruptState // 内嵌:字段提升,访问方式 s.interruptQueues 不变
queue []*Task
...
}
```
- 优点:零调用点改动(字段提升后 `s.immediate` 等写法照旧),搬迁风险最低。
- 缺点:锁定关系是隐式的(仍是同一把 `s.mu`)。
**A2:中断子系统独立持锁**
- 优点:边界更清晰。
- 缺点:`nextRef` 要同时读两个子系统,双锁必然引入顺序问题;且现有代码在
同一临界区里同时改两者,拆锁是**行为变更**,不是重构。
**选择 A1**。理由:本次目标是把**语义集中**,不是改并发模型。A2 属于另一次
独立的设计变更,混在一起会让"行为未变"这个前提失效。
### B. 测试大量直接访问 `s.mu` 与内部字段
现有 `scheduler_preempt_test.go` 等直接构造 `scheduler` 并断言字段。
A1 方案下这些测试**不需修改**(同包 + 字段提升)。若选用 A2 则要改写测试,
进一步说明 A1 是对的。
### C. 搬迁时手滑改坏逻辑
对策:阶段 1 严格"只搬不改",每次搬迁后立即跑测试;用 `git diff --color-moved`
复核搬移是否真的只是移动。
### D. 停止配额的归属 —— 已裁定,不再是风险
见 §1.5:它操作**就绪队列**,归调度器。此条已从"待确认"降为"已定"。
### E. `nextRef` 的归属 —— 已切分
见 §1.6:①②③(immediate / 中断队列 / 栈顶比较与弹栈)归中断子系统
(`nextInterruptSelection`),④(就绪队列)归调度器。`nextRef` 本身留在
`scheduler.go`,但只剩"先问中断侧、无货再取就绪队列"这一层编排。
> 注意:这会让 `nextRef` 的代码量明显变小——那是正确的,因为它原本混装了两件事。
---
## 五、验收判据
- [ ] `interrupt.go` 承载 §2.1 全部条目;`scheduler.go` 不再含中断队列/栈/让位字段
- [ ] 中断不变量有**专门判据**(栈上界不丢帧、L4 不抢 L4、严格 LIFO、临界区只入队)
- [ ] 全量 `go test ./internal/agent/core/` 与基线逐条一致
- [ ] `go test -race ./internal/agent/core/` 通过
- [ ] `git diff --color-moved` 显示阶段 1 为纯搬移
- [ ] 修改一条中断规则时,只需改 `interrupt.go` 一处(**这是本次重构的目的**)
- [ ] 中断向量表落地:三个 L4 来源改为查表,`VectorID` 可被日志引用
- [ ] panic 的递归保护从硬编码改为 `Guard`,只对 panic 条目生效
- [ ] 向量表引入阶段**行为零变化**(`ExpectsReply` 仅预留字段,默认 true)
- [ ] 依赖方向单向:中断子系统**不**读就绪队列/统计/任务 ID 等调度器状态
- [ ] 恢复决策(栈顶弹出、与中断队列比较)在中断子系统内;恢复执行在调度器
- [ ] 「谁先跑」的优先级规则只有一处实现(`nextInterruptSelection`)
- [ ] 停止配额归调度器(不因"也是打断"而误归中断)
---
## 六、不做的事
- 不改中断级别数值(L1..L4 与 clamp 规则保持原样)
- 不改抢占条件、不改栈深度上界
- 不拆包(`package core` 保持不变)
- 不改并发模型(仍共用 `s.mu`)
- 不动 `overflow.go` 的裁剪语义(那是上一轮已完成的工作)
- **不在本次做** `ExpectsReply=false` 的行为变更(§7.5 第 3 步,单独提交)