diff --git a/docs/zh/interrupt-subsystem-split-plan.md b/docs/zh/interrupt-subsystem-split-plan.md index cc631d7d..6ebab925 100644 --- a/docs/zh/interrupt-subsystem-split-plan.md +++ b/docs/zh/interrupt-subsystem-split-plan.md @@ -353,7 +353,7 @@ internal/agent/core/ | 1.5 切断反向依赖 | ⏳ 部分 | 已建立**中断栈状态钩子**(`stackNotify`)实现单向通告;`maxQueue` 仍约束中断队列(见下) | | 2 结构收敛 | ✅ 完成 | 划界落实:机制在 `interrupt.go`,策略在 `scheduler.go` | | 3 验证 | ✅ 完成 | 全仓 `go test -p 1 ./...` 通过;`-race` 三关键包绿;变异验证通过 | -| 中断向量表 | ⏳ 未做 | §7 已设计,按 §7.6 顺序应在拆分完成后单独做 | +| 中断向量表 | ✅ 完成 | `interrupt_vectors.go`:三个 L4 源改为查表;panic 递归保护收进 `Guard`;两次变异验证均变红 | ### 最终边界(用户第四次裁定:队列归调度器) @@ -410,6 +410,46 @@ internal/agent/core/ --- +### 中断向量表落地(2026-10-25,本次拆分的真正目的) + +新增 `internal/agent/core/interrupt_vectors.go`: + +```go +type VectorID string +const ( + VectorTaskPanic, VectorContextOverflow, VectorChildContextFull +) + +type interruptVector struct { + ID VectorID + Level Level // 每条声明自己的级别(内核源 L4) + Source func(vectorArgs) string // 来源标识(child/ 这类动态的在闭包里拼) + Guard func(vectorArgs) bool // 前置判定:panic 的递归保护收在这里 + Build func(vectorArgs) string // 正文(模型唯一可见内容) +} +``` + +三个裸调用点全部改为 `raiseVector(id, args)`: + +| 原调用点 | 现在 | +| --- | --- | +| `reportTaskPanic` 里硬编码递归保护 + 调 `raiseKernelInterrupt` | `raiseVector(VectorTaskPanic, …)`,保护在 `Guard` | +| `raiseContextOverflow` | `raiseVector(VectorContextOverflow, …)` | +| `handleChildContextFull` | `raiseVector(VectorChildContextFull, …)` | + +**未登记 ID 会 panic**,不静默降级——否则打错 ID 会变成"中断发了但没人认领"。 + +判据 `interrupt_vectors_test.go`(7 条):表结构完整性、未登记被拒、 +递归保护生效、**调用点确实走向量表**(行为验证 source 来自表)。 +后两条都做过变异验证:去掉递归保护 ⇒ 红;调用点绕过表 ⇒ 红 +(`source="kernel-direct"`,期望 `"kernel"`)。 + +> ⚠️ `ExpectsReply` 字段**未实现**。按 §7.5/§7.6,它属于行为变更 +> (内核提示不再引起模型整轮回复),必须单独提交、单独验证。 +> 本次只完成了"规则集中"这一步,行为零变化。 + +--- + ## 三、实施步骤 ### 阶段 0:安全网(先做,不可跳过) diff --git a/internal/agent/core/interrupt_vectors.go b/internal/agent/core/interrupt_vectors.go new file mode 100644 index 00000000..37841cf9 --- /dev/null +++ b/internal/agent/core/interrupt_vectors.go @@ -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/)在闭包里拼。 + 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) +} diff --git a/internal/agent/core/interrupt_vectors_test.go b/internal/agent/core/interrupt_vectors_test.go new file mode 100644 index 00000000..39599101 --- /dev/null +++ b/internal/agent/core/interrupt_vectors_test.go @@ -0,0 +1,160 @@ +package core + +// 中断向量表的判据。 +// +// 向量表的价值在于"规则随条目声明,不再靠调用方自律"。但这只有在 +// **表本身正确**、且**调用点真的走表**时才成立。所以这里钉三件事: +// +// V1 表内每条的 ID 与键一致、必备字段齐全、级别是内核级 +// V2 panic 的递归保护真的生效(这是原先硬编码、最容易漏的规则) +// V3 三个内核中断源确实走向量表,而不是绕过它直接调 raiseKernelInterrupt +// +// V3 是最关键的:若某天有人图省事又加一个裸调用点,向量表就退化成 +// 一份"看起来完整、实际没人用"的文档——那种表格比没有更糟。 + +import ( + "strings" + "testing" +) + +// ── V1:表结构完整性 ── + +func TestVectors_TableIsWellFormed(t *testing.T) { + if len(interruptVectors) == 0 { + t.Fatal("向量表为空——三个内核中断源必须登记") + } + for key, v := range interruptVectors { + if v.ID != key { + t.Errorf("向量 %q 的 ID 字段是 %q,与键不一致(手滑会让人查错条目)", key, v.ID) + } + if v.Source == nil { + t.Errorf("向量 %q 缺 Source(来源标识将为空,日志无法定位)", key) + } + if v.Build == nil { + t.Errorf("向量 %q 缺 Build(无法构造中断正文)", key) + } + // Guard 允许为 nil(表示无条件产生),但显式 nil 应该是少数。 + if v.Level != LevelCritical { + t.Errorf("向量 %q 的级别是 %d,内核中断源必须是 L4(LevelCritical)", key, int(v.Level)) + } + } + // 反向:三个源必须都在表里(防漏登记) + for _, id := range []VectorID{VectorTaskPanic, VectorContextOverflow, VectorChildContextFull} { + if _, ok := interruptVectors[id]; !ok { + t.Errorf("中断源 %q 未登记进向量表", id) + } + } +} + +// 未登记的 ID 必须被识别出来(不静默降级成默认级别)。 +func TestVectors_UnknownIDIsRejected(t *testing.T) { + if _, ok := lookupVector(VectorID("no_such_vector")); ok { + t.Fatal("未登记的 ID 不应被 lookupVector 认领") + } +} + +// 未登记时 raiseVector 必须**响亮失败**,而不是静默发一个没人认领的中断。 +func TestVectors_RaiseUnknownPanics(t *testing.T) { + a := newPreemptAgent(t, newPreemptProvider()) + defer func() { + if r := recover(); r == nil { + t.Fatal("raiseVector 未登记 ID 时必须 panic(宁可响亮失败,不要静默发出无人认领的中断)") + } + }() + a.raiseVector(VectorID("no_such_vector"), vectorArgs{}) +} + +// ── V2:panic 的递归保护真的生效 ── + +// 这是向量化最实质的收益:原先硬编码在 reportTaskPanic 里的规则。 +// L4 任务自己 panic 时不得再产生 L4——否则同一个 panic 自我放大成中断风暴。 +func TestVectors_PanicGuardBlocksRecursiveL4(t *testing.T) { + v, _ := lookupVector(VectorTaskPanic) + if v.Guard == nil { + t.Fatal("panic 向量必须有 Guard(递归保护)") + } + + l4 := &Task{Class: TaskInterrupt, Level: LevelCritical} + if v.Guard(vectorArgs{Task: l4, Panic: "boom"}) { + t.Fatal("L4 任务 panic 时不得再产生 L4(会自我放大成中断风暴)") + } + + // 正常任务必须放行——否则上一条可能是"永远拒绝"的假绿。 + normal := &Task{Class: TaskQueued} + if !v.Guard(vectorArgs{Task: normal, Panic: "boom"}) { + t.Fatal("普通任务 panic 必须放行") + } + // L3 中断任务放行(它自己不是 L4,不会造成 L4 自我放大)。 + l3 := &Task{Class: TaskInterrupt, Level: LevelInteractive} + if !v.Guard(vectorArgs{Task: l3, Panic: "boom"}) { + t.Fatal("L3 中断任务 panic 必须放行") + } + // nil 任务不得 panic(防御) + if v.Guard(vectorArgs{Task: nil}) { + t.Fatal("nil 任务不应产生 panic 中断") + } +} + +// ── V3:调用点确实走向量表 ── + +// 向量化若只建表、不改调用点,就是"看起来完整、实际没人用"的文档。 +// 这里用行为验证:触发一次 panic 中断,检查它带着向量表声明的 source。 +func TestVectors_PanicSourceComesFromTable(t *testing.T) { + a := newPreemptAgent(t, newPreemptProvider()) + if a.sched == nil { + t.Skip("未启用调度器") + } + + v, _ := lookupVector(VectorTaskPanic) + want := v.Source(vectorArgs{}) + + // 直接构造中断任务并登记(绕过需运行中任务的抢占判定)。 + a.reportTaskPanic(&Task{ID: 42, Class: TaskQueued}, "boom") + + snap := a.DumpScheduler() + var got string + if snap.Immediate != nil && snap.Immediate.Event != nil { + got = snap.Immediate.Event.Source + } else { + for _, q := range snap.InterruptQueues { + for _, it := range q { + if it.Event != nil { + got = it.Event.Source + } + } + } + } + if got != want { + t.Fatalf("panic 中断的 source=%q,期望向量表声明的 %q(说明调用点没走向量表)", got, want) + } +} + +// 超页中断的正文必须来自向量表的 Build(含"内容被移走"这一关键语义)。 +func TestVectors_OverflowBodyComesFromTable(t *testing.T) { + v, _ := lookupVector(VectorContextOverflow) + if v.Build == nil { + t.Fatal("超页向量必须有 Build") + } + body := v.Build(vectorArgs{Pruned: 7}) + if strings.TrimSpace(body) == "" { + t.Fatal("超页中断正文不能为空(它是模型唯一的认知输入)") + } + // 语义要求:必须让模型知道"有内容被移走了",否则它会把"查不到"推断成"不存在"。 + if !strings.Contains(body, "7") { + t.Fatalf("超页正文应包含被裁剪的条数 7,实际:%q", body) + } +} + +// 驻留子中断的来源必须带子标识(父要据此定位是哪个子),正文必须给出三种处置动作。 +func TestVectors_ChildVectorCarriesIdentity(t *testing.T) { + v, _ := lookupVector(VectorChildContextFull) + if got := v.Source(vectorArgs{ChildID: "kid-1"}); got != "child/kid-1" { + t.Fatalf("驻留子中断来源应为 child/kid-1,实际 %q", got) + } + body := v.Build(vectorArgs{ChildID: "kid-1"}) + for _, want := range []string{"kid-1", "压缩", "回收", "销毁"} { + if !strings.Contains(body, want) { + t.Fatalf("驻留子中断正文缺 %q(父需要完整的处置选项):%q", want, body) + } + } +} diff --git a/internal/agent/core/overflow.go b/internal/agent/core/overflow.go index 988532ed..2f298131 100644 --- a/internal/agent/core/overflow.go +++ b/internal/agent/core/overflow.go @@ -346,7 +346,7 @@ func (a *Agent) raiseContextOverflow(pruned int) { a.overflowStat.LastPruned = pruned a.overflowStat.Unlock() - a.raiseKernelInterrupt("kernel/overflow", "kernel", overflowNotice(pruned)) + a.raiseVector(VectorContextOverflow, vectorArgs{Pruned: pruned}) } // overflowNotice 构造超页中断消息。 diff --git a/internal/agent/core/resident.go b/internal/agent/core/resident.go index 3b3323b2..675f2a3d 100644 --- a/internal/agent/core/resident.go +++ b/internal/agent/core/resident.go @@ -603,8 +603,7 @@ func (a *Agent) handleChildContextFull(rc *residentChild) { rc.mu.Lock() rc.state = "contextfull" rc.mu.Unlock() - a.raiseKernelInterrupt("child/"+rc.id, "kernel", - fmt.Sprintf("内核事件:驻留子 %s 上下文已满(contextfull)。请【查看】其状态面后决定【压缩】/【回收】/【销毁】。", rc.id)) + a.raiseVector(VectorChildContextFull, vectorArgs{ChildID: rc.id}) } // checkContextFull 判断本 agent 的**积累上下文是否已经装不下窗口**,是则触发一次 contextfull。 diff --git a/internal/agent/core/scheduler.go b/internal/agent/core/scheduler.go index 3542c7c8..cb2b7467 100644 --- a/internal/agent/core/scheduler.go +++ b/internal/agent/core/scheduler.go @@ -1181,7 +1181,17 @@ func newSelfTask(msg selfInputMsg) *Task { // 这是 L4 的唯一来源:panic 中断、内核事件中断(selfip)。 // 插件永远拿不到这个入口——它不经 InjectOptions,也不经 proc 桥。 func newKernelInterruptTask(evt *agentIO.InputEvent) *Task { - return &Task{Class: TaskInterrupt, Kind: TaskKindInput, Level: LevelCritical, Event: evt, EnqueuedAt: time.Now()} + return newKernelInterruptTaskAt(evt, LevelCritical) +} + +// newKernelInterruptTaskAt 按指定级别构造内核中断任务。 +// +// 内核源不夹取级别(与插件源不同):向量表里登记的级别就是最终级别。 +func newKernelInterruptTaskAt(evt *agentIO.InputEvent, level Level) *Task { + if level < LevelBackground || level > LevelCritical { + level = LevelCritical + } + return &Task{Class: TaskInterrupt, Kind: TaskKindInput, Level: level, Event: evt, EnqueuedAt: time.Now()} } // DumpScheduler 返回调度器的原子快照(供状态页/测试断言)。 @@ -1350,8 +1360,20 @@ func (a *Agent) pumpInbox() { // 能否抢占由调度器按统一判据决定;若会抢占,则顺手取消可取消的 LLM 流式步骤 // (与 interceptLoop 对插件中断的处理完全一致)。 func (a *Agent) raiseKernelInterrupt(source, channel, text string) { + a.raiseKernelInterruptAt(source, channel, text, LevelCritical) +} + +// raiseKernelInterruptAt 是 raiseKernelInterrupt 的**带级别**版本。 +// +// 级别由调用方给出(中断向量表里登记的那个),而不是在这里写死 L4: +// 向量表的每条都声明了自己的 Level,这条通道必须能表达它, +// 否则"级别由向量声明"就成了一句空话。 +// +// 注意:外部插件走的不是这条路(它们经 interruptLevel 解析 + clampPluginLevel +// 夹取),这里只服务内核自己登记的中断源。 +func (a *Agent) raiseKernelInterruptAt(source, channel, text string, level Level) bool { if a.sched == nil { - return + return false } evt := &agentIO.InputEvent{ Source: source, @@ -1365,9 +1387,11 @@ func (a *Agent) raiseKernelInterrupt(source, channel, text string) { "kernel": true, }, } - if a.sched.requestKernelPreempt(evt) { + armed := a.sched.registerInterrupt(newKernelInterruptTaskAt(evt, level)) + if armed { a.cancelCurrentLLM() } + return armed } // reportTaskPanic 把一个任务 panic 报告成内核 L4 中断。 @@ -1375,11 +1399,9 @@ func (a *Agent) raiseKernelInterrupt(source, channel, text string) { // 递归保护是**结构性**的:若 panic 的任务本身就是 L4 内核中断,则不再产生新的 // L4——否则同一个 panic 会自我放大成中断风暴,与“内核事件”应有的语义相反。 func (a *Agent) reportTaskPanic(t *Task, r interface{}) { - if t.Class == TaskInterrupt && t.Level >= LevelCritical { - return - } - a.raiseKernelInterrupt("kernel", "kernel", - fmt.Sprintf("内核事件:任务 #%d 发生 panic:%v(该任务已被丢弃,调度器存活)", t.ID, r)) + // 递归保护("L4 panic 不再产生 L4")已收进向量表的 Guard, + // 不再硬编码在这里——新增中断源时不会漏掉这条规则。 + a.raiseVector(VectorTaskPanic, vectorArgs{Task: t, Panic: r}) } func (a *Agent) executeTask(t *Task) {