mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-01 14:57:38 +00:00
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:
534
docs/zh/interrupt-subsystem-split-plan.md
Normal file
534
docs/zh/interrupt-subsystem-split-plan.md
Normal 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 步,单独提交)
|
||||
Reference in New Issue
Block a user