feat(agent/core): 中断向量表 —— 三个 L4 源统一登记,行为零变化

用户原话:「需要添加中断向量系统,独立成中断子模块就是为了方便添加这个
中断向量系统」。本次落地该目的。

## 为什么需要向量表

拆分前"有哪些内核中断、各自什么语义"散落在三个裸调用点上:

    raiseKernelInterrupt("kernel",          "kernel", "任务 #N panic…")
    raiseKernelInterrupt("kernel/overflow", "kernel", "上下文超页…")
    raiseKernelInterrupt("child/"+rc.id,    "kernel", "驻留子已满…")

三者差异**只在 source 字符串**上,语义契约全靠注释与调用方自律。
最能说明问题的是 panic 的递归保护:

    if t.Class == TaskInterrupt && t.Level >= LevelCritical { return }
    // 否则同一个 panic 会自我放大成中断风暴

这条规则只属于"panic 中断",却硬编码在 reportTaskPanic 里。
**新增一种内核中断时,没人会记得要不要也加递归保护**。

## 设计

interruptVector 声明一条中断源的完整契约:
  ID / Level / Source / Guard / Build

- Level 由条目自己声明(不再写死 L4)
- Guard 收容前置判定 —— panic 的递归保护落在这里
- Build 构造正文(模型唯一可见内容)
- 未登记 ID **panic**,不静默降级(打错 ID 会变成"中断发了但没人认领")

## 改动

新增 interrupt_vectors.go + interrupt_vectors_test.go(7 条判据)
三个调用点全部改为 raiseVector(id, args)
新增 raiseKernelInterruptAt / newKernelInterruptTaskAt 以表达条目声明的级别

## 行为零变化(刻意)

- 三个源的 source/正文/级别与改动前逐字一致
- ExpectsReply 字段**未实现**:按计划 §7.5/§7.6,它属于行为变更
  (内核提示不再引起模型整轮回复),必须单独提交、单独验证

## 变异验证

- 去掉 panic 递归保护 ⇒ TestVectors_PanicGuardBlocksRecursiveL4 红
- 调用点绕过向量表 ⇒ TestVectors_PanicSourceComesFromTable 红
  (source="kernel-direct",期望 "kernel")

## 验证

go build ./... / go test ./internal/agent/core/ / go test -race 全通过
This commit is contained in:
JianFeeeee
2026-10-25 14:45:36 +08:00
parent 01bef35c89
commit 8d5e31f7c0
6 changed files with 390 additions and 12 deletions

View File

@ -0,0 +1,157 @@
package core
// 中断向量表 —— 中断源的**统一登记处**。
//
// ## 为什么需要它(这是中断子系统独立出来的真正目的)
//
// 拆分之前,"有哪些内核中断、各自什么语义"散落在三个裸调用点上:
//
// raiseKernelInterrupt("kernel", "kernel", "任务 #N panic…")
// raiseKernelInterrupt("kernel/overflow", "kernel", "上下文超页…")
// raiseKernelInterrupt("child/"+rc.id, "kernel", "驻留子已满…")
//
// 三者差异**只在 source 字符串**上,语义契约全靠注释与调用方自律。
// 最能说明问题的是 panic 的递归保护:
//
// if t.Class == TaskInterrupt && t.Level >= LevelCritical { return }
// // 否则同一个 panic 会自我放大成中断风暴
//
// 这条规则只属于"panic 中断",却硬编码在 reportTaskPanic 里。
// **新增一种内核中断时,没人会记得要不要也加递归保护**——这就是要向量化的理由。
//
// ## 边界
//
// 向量表属于**进入中断**的入口定义,因此住在中断管理器这一侧
// (用户裁定:中断管理器负责"进入中断")。它只回答"这次中断长什么样、
// 谁能产生它",**不参与**排队、优先级比较、恢复选择。
//
// ## 加一种新的内核中断
//
// 只改这一个文件:在 VectorID 里加常量、在 interruptVectors 里加一条。
// 规则(级别/前置判定/载荷构造/来源标识)随条目声明,不再靠调用方自律。
import "fmt"
// VectorID 是中断源的**稳定标识**。
//
// 与 source 字符串的区别:source 是给人和日志看的(如 "child/xxx"),
// 会随实例变化;VectorID 是稳定的枚举值,可被诊断面、指标、日志字段引用,
// 也可用来回答"现在一共登记了几种内核中断"。
type VectorID string
const (
// VectorTaskPanic:任务 panic。带递归保护(见 interruptVectors)。
VectorTaskPanic VectorID = "task_panic"
// VectorContextOverflow:上下文超页,需要提示模型"内容被移走了"。
VectorContextOverflow VectorID = "ctx_overflow"
// VectorChildContextFull:驻留子上下文已满,父据此决定压缩/回收/销毁。
VectorChildContextFull VectorID = "child_ctxfull"
)
// vectorArgs 是一次中断向量调用的全部入参。
//
// 用显式结构体而不是 `any`:每种中断用哪些字段一眼可见,
// 且新增字段时编译器会提醒所有构造点。
type vectorArgs struct {
// Task 是发生 panic 的任务(VectorTaskPanic 使用)。
Task *Task
// Panic 是 recover() 取到的值(VectorTaskPanic 使用)。
Panic any
// Pruned 是超页时被裁剪的消息条数(VectorContextOverflow 使用)。
Pruned int
// ChildID 是驻留子标识(VectorChildContextFull 使用)。
ChildID string
}
// interruptVector 是一个中断源的**完整契约**。
type interruptVector struct {
// ID 是稳定标识。
ID VectorID
// Level 是该源产生的中断级别。内核源恒为 L4(LevelCritical)。
Level Level
// Source 构造来源标识(写进 payload 的 interrupt_source)。
// 需要动态前缀的源(如 child/<id>)在闭包里拼。
Source func(a vectorArgs) string
// Guard 是登记前的**前置判定**,返回 false 表示本次不产生中断。
//
// 这是原先散落在调用方里的规则的收容处——例如 panic 的递归保护:
// 若 panic 的任务本身就是 L4 内核中断,就不能再产生新的 L4,
// 否则同一个 panic 会自我放大成中断风暴。
Guard func(a vectorArgs) bool
// Build 构造中断正文(模型能看到的唯一内容)。
Build func(a vectorArgs) string
}
// interruptVectors 是中断源的登记表。
//
// 判据(interrupt_vectors_test.go):
// - 表内 ID 必须与 interruptVector.ID 一致(防手滑)
// - 每条必须有 Source/Guard/Build,且 Level 必须是内核级
// - panic 条目必须能挡住"L4 panic 再产生 L4"(递归保护)
var interruptVectors = map[VectorID]interruptVector{
VectorTaskPanic: {
ID: VectorTaskPanic,
Level: LevelCritical,
Source: func(vectorArgs) string { return "kernel" },
Guard: func(a vectorArgs) bool {
// 递归保护是**结构性**的(原先硬编码在 reportTaskPanic 里):
// 若 panic 的任务本身就是 L4 内核中断,则不再产生新的 L4——
// 否则同一个 panic 会自我放大成中断风暴,与"内核事件"的语义相反。
if a.Task == nil {
return false
}
return !(a.Task.Class == TaskInterrupt && a.Task.Level >= LevelCritical)
},
Build: func(a vectorArgs) string {
return fmt.Sprintf("内核事件:任务 #%d 发生 panic:%v(该任务已被丢弃,调度器存活)",
a.Task.ID, a.Panic)
},
},
VectorContextOverflow: {
ID: VectorContextOverflow,
Level: LevelCritical,
Source: func(vectorArgs) string { return "kernel/overflow" },
Guard: func(vectorArgs) bool { return true },
Build: func(a vectorArgs) string { return overflowNotice(a.Pruned) },
},
VectorChildContextFull: {
ID: VectorChildContextFull,
Level: LevelCritical,
Source: func(a vectorArgs) string { return "child/" + a.ChildID },
Guard: func(vectorArgs) bool { return true },
Build: func(a vectorArgs) string {
// 父子协议:只推信号,细节靠"查看"拉状态面。
return fmt.Sprintf("内核事件:驻留子 %s 上下文已满(contextfull)。"+
"请【查看】其状态面后决定【压缩】/【回收】/【销毁】。", a.ChildID)
},
},
}
// lookupVector 取一个向量;未登记时返回 false(**不静默降级**)。
//
// 未登记必须响亮失败而不是悄悄用默认级别——否则新增中断源时打错 ID
// 会变成"中断发了但没人认领",比不编译还难查。
func lookupVector(id VectorID) (interruptVector, bool) {
v, ok := interruptVectors[id]
return v, ok
}
// raiseVector 是登记一次内核中断的**统一入口**。
//
// 与直接调用 raiseKernelInterrupt 的区别:级别、来源标识、前置判定、
// 正文构造全部来自向量表,调用点只声明"哪一类中断 + 载荷"。
//
// 返回 true 表示中断真的占了 immediate 槽(会立即生效)。
func (a *Agent) raiseVector(id VectorID, args vectorArgs) bool {
v, ok := lookupVector(id)
if !ok {
// 未登记是**编码错误**:宁可响亮失败,也不要静默发出一个没人认领的中断。
panic(fmt.Sprintf("core: 未登记的中断向量 %q(请在 interrupt_vectors.go 里登记)", id))
}
if v.Guard != nil && !v.Guard(args) {
return false
}
return a.raiseKernelInterruptAt(v.Source(args), "kernel", v.Build(args), v.Level)
}