Files
HomeAgent/internal/agent/core/scheduler_rearm_test.go
JianFeeeee c321388a21 fix(scheduler): 安全点重新求值中断队列 + 抢占/背压计数修正 + 停机补终态
对照 docs/zh/input-scheduler-design.md 原文修四处(前两处是真缺陷,后两处是
观测面与设计承诺不一致),均配回归用例:

1. §4.3/§5.2「临界区结束后的第一个安全点重新求值」此前**没有实现**:
   全仓唯一的武装点是 registerInterrupt,凡被拦成「入队」的中断只能等当前任务
   自然结束。可达症状:WebUI 终止按钮连按两次,第二次落在 2s 抢占冷却窗内 →
   入队 → 再也不会被求值。修:runTaskSteps 的安全点先 rearmPending()——
   判据与 registerInterrupt 完全同一套(canPreempt + 冷却 + 临界区闸门)。

2. PreemptsByLevel 的语义是「进入 immediate 槽的次数」,但计数发生在
   setImmediateLocked 之前:immediate 是单槽,同一安全点前到达的两条同级中断里
   被降级的那条也被计成抢占。修:setImmediateLocked 只在真占住槽时返回 true,
   计数随之为真;同时把「降级入队」的责任收归调用方,消除同一任务被入队两次的
   隐患(实测该隐患会让中断任务执行两次、Executed 虚高)。

3. 状态面 Preempted 此前拿 Stats.Suspended 顶替,与 preempts_by_level 自相矛盾。
   修:Preempted = Σ PreemptsByLevel[1..4]。

4. §4.4/Q4「满时阻塞发送方 + 计数并打日志」只做了阻塞:pumpInbox 满时直接返回,
   一个字都不计。修:新增 Stats.Backpressure(+DTO 字段) 与只报一次的状态翻转日志;
   同时显式处理 enqueue 返回值(静默丢弃会让同步调用方永久挂起)。

另:Stop() 停机前排空待办——给从未运行与已挂起的、带 ResponseCh 的任务补
skipped 终态,否则 cli/clawhubadapter 这类无超时同步注入方永久挂起(§7 I5、§11.3 X4)。
emitResponse 的 ResponseCh 写入改为非阻塞 + 告警,避免一行写错就卡死调度器 goroutine。

验证:go build/vet 干净;go test -count=1 ./internal/agent/... ./internal/plugin/...
./internal/sdk/... ./cmd/... 全绿;go test -race ./internal/agent/core/ ./internal/sdk/ 干净。
新增 scheduler_rearm_test.go 六个用例(冷却期满重新求值/同级降级不计数/Preempted 求和/
停机补终态/背压计数与翻转/pumpInbox 满计数)。
2026-09-14 10:39:25 +08:00

187 lines
7.8 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
// 回归测试:安全点「重新求值」、抢占计数语义、停机补终态、背压计数。
//
// 对照设计稿原文修正的四条:
//
// 1. §4.3/§5.2 —— 临界区期间到达的抢占请求「不丢失:按级别进入中断队列,
// 在**临界区结束后的第一个安全点重新求值**」。实现里此前没有这一步:
// 唯一的武装点是 registerInterrupt凡被拦成「入队」的中断只能等当前任务
// **自然结束**。可复现症状WebUI 终止按钮连按两次,第二次落在 2s 抢占冷却
// 窗内 → 入队 → 再也不会被求值,「终止」看起来没反应。
// 2. SchedulerStats.PreemptsByLevel 的语义是「判定可抢占**并进入 immediate** 的
// 次数」;此前在 setImmediateLocked 之前就计数,于是同一安全点前到达的两条同级
// 中断里、被降级入队的那条也被计入immediate 是单槽,降级是设计要求的路径)。
// 3. 状态面 Preempted 此前直接拿 Stats.Suspended 顶替,与 preempts_by_level 自相矛盾。
// 4. §4.4 / §11.4 Q4 —— 就绪队列满必须「阻塞发送方 + **计数并打日志**」。
import (
"testing"
"time"
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
)
// 被冷却拦成入队的中断,在冷却期满后的第一个安全点必须被重新武装。
//
// 这是「终止按钮连按两次」的最小复现:第一次抢占成功(受害者进入 2s 冷却),
// 第二次在冷却窗内只能入队——修复前它就永远等不到执行了。
func TestRearm_CooldownExpiryPromotesQueuedInterrupt(t *testing.T) {
a := newPreemptAgent(t, newPreemptProvider())
victim := &Task{ID: 1, Class: TaskInterrupt, Level: LevelBackground, EnqueuedAt: time.Now()}
a.sched.immediate = victim
a.sched.nextRef() // running = victim
// 模拟「刚被抢占过」:冷却起点就在此刻,且抢占提升已生效(有效级 L2
victim.PreemptCount = 1
victim.LastPreemptAt = time.Now()
evt, _ := textEvent("cli", "第二次终止")
evt.Payload["interrupt"] = true
if a.sched.requestPreempt(evt, LevelCritical) {
t.Fatal("抢占冷却期内不得抢占(应入队)")
}
if got := a.sched.stats.PreemptsByLevel[LevelCritical]; got != 0 {
t.Fatalf("被冷却拦成入队的中断不得计入抢占数,实际 %d", got)
}
if n := len(a.sched.interruptQueues[LevelCritical]); n != 1 {
t.Fatalf("应恰好入队一条,实际 %d>1 说明入队路径重复)", n)
}
// 冷却期满 → 安全点的「重新求值」必须把它武装起来(修复前缺失的正是这一步)。
victim.LastPreemptAt = time.Now().Add(-3 * time.Second)
a.sched.rearmPending()
if !a.sched.preemptGrantedFor() {
t.Fatal("冷却期结束后应重新武装让位信号(设计 §4.3「第一个安全点重新求值」)")
}
snap := a.DumpScheduler()
if snap.Immediate == nil {
t.Fatal("重新求值后应把该中断提升进 immediate 槽")
}
if got := snap.Stats.PreemptsByLevel[LevelCritical]; got != 1 {
t.Fatalf("真正占住 immediate 才能计一次抢占,实际 %d", got)
}
// 重新求值不该把任务复制一份队列必须空、immediate 恰好一条。
if n := len(snap.InterruptQueues[LevelCritical]); n != 0 {
t.Fatalf("提升后 L4 队列应空,实际 %d", n)
}
}
// 同一安全点前到达的两条同级中断immediate 是单槽,第二条只能降级入队;
// 它**没有**进入 immediate因此不得计入 PreemptsByLevel也不得被入队两次。
func TestRearm_SameLevelSecondPreempterIsQueuedNotCounted(t *testing.T) {
a := newPreemptAgent(t, newPreemptProvider())
victim := &Task{ID: 1, Class: TaskQueued, EnqueuedAt: time.Now()}
a.sched.immediate = victim
a.sched.nextRef() // running = 排队任务(有效级 0任何中断都能抢
e1, _ := textEvent("cli", "irq-1")
if !a.sched.requestPreempt(e1, LevelInteractive) {
t.Fatal("第一条 L3 应抢占排队任务")
}
e2, _ := textEvent("cli", "irq-2")
a.sched.requestPreempt(e2, LevelInteractive) // 同级 → 降级入队
snap := a.DumpScheduler()
if got := snap.Stats.PreemptsByLevel[LevelInteractive]; got != 1 {
t.Fatalf("被降级的同级第二条不得计入抢占数(期望 1实际 %d", got)
}
if n := len(snap.InterruptQueues[LevelInteractive]); n != 1 {
t.Fatalf("被降级的那条应在 L3 队列里**恰好**出现一次,实际 %d", n)
}
if snap.Immediate == nil {
t.Fatal("第一条应留在 immediate 槽,两条都不能丢")
}
}
// 状态面 Preempted 必须是「各级抢占数之和」,不能拿 Suspended 顶替。
func TestStatus_PreemptedEqualsSumOfLevels(t *testing.T) {
a := New(AgentConfig{ID: "rt-sum", ProviderManager: agentAPI.NewProviderManager(), IO: agentIO.NewIOManager()})
if a.sched == nil {
t.Fatal("agent 应带调度器")
}
a.sched.mu.Lock()
a.sched.stats.PreemptsByLevel[LevelBackground] = 2
a.sched.stats.PreemptsByLevel[LevelInteractive] = 3
a.sched.stats.Suspended = 99 // 故意与抢占数不等
a.sched.mu.Unlock()
got := a.schedulerStatus()
if got.Preempted != 5 {
t.Fatalf("Preempted 应为各级抢占数之和 5实际 %d拿 Suspended 顶替会得 99", got.Preempted)
}
if got.Suspended != 99 {
t.Fatalf("Suspended 应原样透传,实际 %d", got.Suspended)
}
}
// 停机必须给「从未运行」与「已挂起」的同步任务补终态,
// 否则 cli / clawhubadapter 这类无超时的同步注入方会永久挂起(设计 §7 I5、§11.3 X2/X4
func TestStop_DrainsPendingSyncTasks(t *testing.T) {
a := newPreemptAgent(t, newPreemptProvider())
queuedEvt, queuedCh := textEvent("cli", "排队中,永远不会被调度")
if !a.sched.enqueue(newInputTask(queuedEvt)) {
t.Fatal("入队失败")
}
suspEvt, suspCh := textEvent("cli", "已挂起,停机时不会恢复")
a.sched.suspend(
&Task{ID: 2, Class: TaskInterrupt, Level: LevelInteractive, EnqueuedAt: time.Now(), Event: suspEvt},
a.newTaskFrame("挂起", a.stageCtxFromInput("挂起", "", "")),
)
a.Stop()
for name, ch := range map[string]chan *agentIO.OutputEvent{"排队": queuedCh, "挂起": suspCh} {
select {
case r := <-ch:
if r == nil || r.Payload["skipped"] != true {
t.Fatalf("%s 任务停机时应补 skipped 终态,实际 %+v", name, r)
}
case <-time.After(2 * time.Second):
t.Fatalf("%s 任务停机未补终态(同步调用方会永久挂起)", name)
}
}
}
// 背压计数:持续满只报一次「翻转」不发生;计数本身每次都要累加。
func TestBackpressure_CounterAndTransition(t *testing.T) {
s := newScheduler(1)
if !s.noteBackpressure() {
t.Fatal("首次背压应报告「翻转」")
}
if s.noteBackpressure() {
t.Fatal("持续背压不得重复报告翻转(否则日志会被刷爆)")
}
if s.stats.Backpressure != 2 {
t.Fatalf("背压计数应为 2实际 %d", s.stats.Backpressure)
}
s.clearBackpressure()
if !s.noteBackpressure() {
t.Fatal("队列恢复后再满应再次报告翻转")
}
}
// 集成:就绪队列满时 pumpInbox 必须计一次背压Rejected 保持 0——背压不是丢弃
func TestBackpressure_PumpInboxCountsWhenFull(t *testing.T) {
a := newPreemptAgent(t, newPreemptProvider())
a.sched.maxQueue = 1
a.io.InjectInput("cli", "text", map[string]interface{}{"content": "第一条"})
a.io.InjectInput("cli", "text", map[string]interface{}{"content": "第二条"})
a.pumpInbox()
snap := a.DumpScheduler()
if len(snap.Queue) != 1 {
t.Fatalf("maxQueue=1 时队列应恰好 1 条,实际 %d", len(snap.Queue))
}
if snap.Stats.Backpressure == 0 {
t.Fatal("队列满必须计一次背压(设计 §4.4/Q4阻塞发送方 + 计数)")
}
if snap.Stats.Rejected != 0 {
t.Fatalf("背压不是丢弃Rejected 必须保持 0实际 %d", snap.Stats.Rejected)
}
}