mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-01 06:47:29 +00:00
批 1,不含 SDK 仓与目录改名。 - go.mod 第 1 行 module 路径 - 366 个 .go 文件的完整前缀 github.com/JianFeeeee/HomeAgent 机械替换 (857 处,仅完整前缀;展示名/注释里的 HomeAgent 字样留给批 5) - 未改:third_party 目录名、SDK 自身 go.mod module、go.mod replace 目标、 homed/waiter 可执行名、.hmap 后缀
187 lines
7.8 KiB
Go
187 lines
7.8 KiB
Go
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 "github.com/JianFeeeee/HologramAgent/internal/agent/api"
|
||
agentIO "github.com/JianFeeeee/HologramAgent/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)
|
||
}
|
||
}
|