Files
HomeAgent/internal/agent/core/interrupt_invariant_test.go
JianFeeeee 7cef7df6f2 test: 判据改为面向接口输入输出 + 文档立规 + CI 补缺口
本提交是三件事的第一批,后续按计划分步推进。

## 一、判据改造(两条实证)

### 1. turn_hint 判据原是自证循环

原测试在测试里**手动重放**了真实注入逻辑:

    f.Msgs = a.buildMessages(...)
    hint := stageHint(f.StageCtx, "turn_hint")
    f.Msgs = append(f.Msgs, Message{Role:"system", Content:"[Turn hints] "+hint})
    // 然后断言"最后一条是 system、内容含 [Turn hints]"

它断言的是**它自己刚 append 的那一行** —— 把 task.go:656-661 抄进测试
再验证抄得对不对。真实代码那段删掉,它照样绿。

改为:把 turn_hint 放进 StageCtx,跑**真实的 stepPrepare**
(newTaskFrame 起始就是 StepPrepare),检查它产出的 Msgs。

变异验证:删掉 task.go 里 `if hint := stageHint(...)` 那段 ⇒ 实测红。

### 2. webui 文件下发判据原是白盒

原测试直接调未导出的 `stageWebFile(src, isImage)`、断言 `webFilesDir`
包级变量 —— 那都是**实现方式**。

改为从**输出通道 handler** 走(`RegisterOutputChannel("webui", …)`,
agent 发图/发文件时走的就是它):

    输入:{payload: "<路径或URL>", type: "image"|"file"}
    输出:{status:"ok", url:"/files/<name>.<ext>", size:N}

4 条判据(新版 handler_files_contract_test.go):
  · 图片 → 可下载 URL(前缀/扩展名/size)
  · 远端 URL 原样透传(不下载)
  · 危险扩展名被中和(.html 保留会在 webui 域执行脚本)
  · 空 payload 被拒

变异验证:丢扩展名 ⇒ 红;远端也拷贝 ⇒ 红。

## 二、文档立规:engineering-lessons 新增第五节

原来只有「判据会骗人」,没有「判据本身形状错了」。新增 6 小节:

    5.1  判据必须把被测模块当黑箱(含"如果重写内部实现、接口不变,
         这条判据还会绿吗"这个可操作判据)
    5.2  禁止读源码做断言(附正确替代表)
    5.2.1 判据不得以文档为依据
    5.3  测试文件与业务代码分离(现状:521 文件 / 11.2 万行,
         agent/core 一个包 196 文件里 144 个是测试)
    5.4  CI 必须覆盖每个独立 module
    5.5  CI 与本地必须跑同一套门

§5.2.1 的直接触发:`interrupt_invariant_test.go` 里有一条判据写着
「事实清单(来自探测,见 docs/zh/interrupt-subsystem-split-plan.md §1.5)」
—— 被引用方自己标着「状态:计划(未实施)」,而那条判据的实际断言
只是"入队后队列里 1 条",与声称的"反向依赖现状快照"毫无关系。
该判据已删除(入队行为已由 scheduler_preempt_test 的黑箱断言覆盖)。

## 三、CI 补两个缺口

### gofmt 门

提交纪律要求 `gofmt -l` 干净,但三个 workflow 里 `gofmt` 出现 **0 次**
—— 纪律只靠人自觉。补上后未格式化的代码进不了主干。

只查本仓自管目录:SDK 是独立仓,格式由它自己的 CI 管。

### SDK 判据步骤

`third_party/hologramagentsdk/` 有自己独立的 go.mod:

    go list ./... | grep third_party   →  0 个

主仓 `go test ./...` **永远不包含** SDK 任何测试。实测 SDK 有 30 个判据
文件,主仓 CI 一次都没跑过。

补的步骤只跑主仓 git 跟踪的文件(CI 上只有 22 个 vendored 文件,
工作机上是完整独立仓)—— 实测 `git archive` 出 22 个文件后
`go test ./sdk/ ./meta/` 全绿。

## 四、SDK 仓测试迁移(独立仓,已单独提交 3a38ef5)

`sdk/{plugin,proxy,stress}_test.go` → `sdk/test/`,包名 `sdk` → `test`。

关键:原测试直接写 `&PluginSDK{regStage: X, name: "test"}` —— **私有字段**。
而 `New(name, sett, regTool, regStage, regAPI, regOutput)` 本来就把所有
registrar 作为参数,等价且是生产路径。共改 19 处,0 处剩余。

主仓同步更新了跟踪路径(这三个文件主仓也 vendor 了一份)。

## 验证

    go build ./...            PASS
    go vet ./...              PASS
    gofmt -l                  干净
    go test ./... -count=1    62 包全过,0 FAIL
    干净检出(worktree)       build + 改造后的判据全绿

## 后续(按计划分步)

  步骤 2  webui 29 个测试分类迁移(已实测出精确分类)
  步骤 3  agent/core 739 处白盒引用先分类再动
  步骤 4  SDK tools/hmapdev(package main,无法迁出,只加说明)
  步骤 5  CI 补 coverage + staticcheck
  步骤 6  文档双语(需定术语口径)
2026-11-04 18:11:06 +08:00

326 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 (
"reflect"
"testing"
)
// ── 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)
}
}
// ── I0:边界不变量 —— 队列归调度器,中断管理器只管现场保护 ──
// 用户栽定:「中断管理器只负责进入中断和中断栈的部分」、
// 「中断队列就该调度器管理」。
//
// 这条判据用**结构事实**钉住边界,而不是靠注释:
// interruptState 里只允许出现现场保护/屏蔽相关字段;
// 一旦有人把队列或优先级策略塞回来,这里立刻变红。
func TestInvariant_InterruptManagerHoldsOnlyStackState(t *testing.T) {
// 反射枚举 interruptState 的字段名,白名单校验。
typ := reflect.TypeOf(interruptState{})
allowed := map[string]bool{
"suspendStack": true, // 中断栈
"critical": true, // 中断屏蔽
"preemptArmed": true, // 让位信号
"preemptLevel": true, // 让位信号
"maxInterruptFrames": true, // 栈上界
"stackNotify": true, // 栈状态钩子
}
var bad []string
for i := 0; i < typ.NumField(); i++ {
name := typ.Field(i).Name
if !allowed[name] {
bad = append(bad, name)
}
}
if len(bad) > 0 {
t.Fatalf("中断管理器不应持有这些字段(队列/优先级归调度器): %v", bad)
}
// 反向确认:队列确实在 scheduler 上(而不是"两边都没有"的假绿)。
st := reflect.TypeOf(scheduler{})
for _, want := range []string{"interruptQueues", "immediate"} {
if _, ok := st.FieldByName(want); !ok {
t.Fatalf("中断队列 %s 应在 scheduler 上(排队是调度职责)", want)
}
}
}
// ── 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,否则自死锁)")
}
}