mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-21 17:38:10 +00:00
设计依据 docs/zh/input-scheduler-design.md §9、§11.5(G1/G2)。 - Task 增加 PreemptCount / LastPreemptAt;effectiveLevel(t) = min(L4, Level + min(PreemptCount, 2)):被抢占越多越“值钱”, 逐步追上抢占它的流,但封顶 L4 因而抢不过真正的紧急输入 - 选择函数 taskBefore 改用有效级;requestPreempt 与 preemptGrantedFor 同样以有效级比较 - 抢占冷却 preemptCooldown=2s:刚被抢占的任务期内不再被抢占, 避免同一任务被反复打断到永不完结 - 新增 scheduler_starvation_test.go 4 项:提升与封顶、冷却期内不得再抢占、 提升后同级不得抢占而更高可、选择函数确实用有效级 - 验收:agent 全量 + -race;全仓 build/vet 通过
298 lines
9.8 KiB
Go
298 lines
9.8 KiB
Go
package core
|
||
|
||
// M3b 验收测试:四级优先级 + 严格大于抢占 + 现场保存/恢复 + suspendPool。
|
||
//
|
||
// 设计依据 docs/zh/input-scheduler-design.md §11(P1–P4、R1、R5、D1T、Q3)。
|
||
//
|
||
// 测试手法:用一个「第一次调用阻塞到 ctx 取消、之后按脚本返回」的 provider,
|
||
// 让测试可以确定性地把运行任务停在 S_LLM 上,再注入中断观察让位与恢复。
|
||
|
||
import (
|
||
"context"
|
||
"sync"
|
||
"testing"
|
||
"time"
|
||
|
||
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||
)
|
||
|
||
// preemptProvider 第 1 次 Chat 阻塞直到 ctx 取消;第 2 次起返回脚本。
|
||
type preemptProvider struct {
|
||
mu sync.Mutex
|
||
calls int
|
||
entered chan struct{}
|
||
enteredOn sync.Once
|
||
responses []string
|
||
}
|
||
|
||
func newPreemptProvider(responses ...string) *preemptProvider {
|
||
return &preemptProvider{entered: make(chan struct{}), responses: responses}
|
||
}
|
||
|
||
func (p *preemptProvider) Name() string { return "preempt" }
|
||
|
||
func (p *preemptProvider) Chat(ctx context.Context, req *agentAPI.CompletionRequest) (*agentAPI.CompletionResponse, error) {
|
||
p.mu.Lock()
|
||
p.calls++
|
||
n := p.calls
|
||
p.mu.Unlock()
|
||
|
||
if n == 1 {
|
||
p.enteredOn.Do(func() { close(p.entered) })
|
||
<-ctx.Done()
|
||
return nil, ctx.Err()
|
||
}
|
||
p.mu.Lock()
|
||
defer p.mu.Unlock()
|
||
i := n - 2
|
||
if i < len(p.responses) {
|
||
return &agentAPI.CompletionResponse{Content: p.responses[i]}, nil
|
||
}
|
||
return &agentAPI.CompletionResponse{Content: "done"}, nil
|
||
}
|
||
|
||
func (p *preemptProvider) ChatStream(ctx context.Context, req *agentAPI.CompletionRequest) (<-chan agentAPI.StreamChunk, error) {
|
||
return nil, context.Canceled
|
||
}
|
||
func (p *preemptProvider) MaxContextTokens() int { return 8192 }
|
||
|
||
func (p *preemptProvider) callCount() int {
|
||
p.mu.Lock()
|
||
defer p.mu.Unlock()
|
||
return p.calls
|
||
}
|
||
|
||
func newPreemptAgent(t *testing.T, sp agentAPI.Provider) *Agent {
|
||
t.Helper()
|
||
return New(AgentConfig{
|
||
ID: "preempt",
|
||
Provider: sp,
|
||
ProviderManager: agentAPI.NewProviderManager(),
|
||
IO: agentIO.NewIOManager(),
|
||
StageHost: NewStageHost(),
|
||
})
|
||
}
|
||
|
||
func enqueueTask(t *testing.T, a *Agent, level Level, source, content string) (*Task, *agentIO.InputEvent) {
|
||
t.Helper()
|
||
evt, _ := textEvent(source, content)
|
||
task := &Task{Kind: TaskKindInput, Level: level, Event: evt, EnqueuedAt: time.Now()}
|
||
if !a.sched.enqueue(task) {
|
||
t.Fatal("入队失败")
|
||
}
|
||
return task, evt
|
||
}
|
||
|
||
// P1 + R1 + R5 + D1=A:高优先级抢占 → 挂起在 S_LLM → 中断任务带只读前缀 →
|
||
// 恢复后从 S_LLM 重发,且原任务的 msgs 未被改动。
|
||
func TestPreempt_HigherPreemptsAndResumes(t *testing.T) {
|
||
sp := newPreemptProvider("intr-done", "low-done")
|
||
a := newPreemptAgent(t, sp)
|
||
|
||
lowTask, _ := enqueueTask(t, a, LevelBackground, "qq", "低优先级任务")
|
||
lt, _, kind := a.sched.nextRef()
|
||
if kind != nextReady || lt != lowTask {
|
||
t.Fatalf("应取到低优先级任务,kind=%v", kind)
|
||
}
|
||
|
||
done := make(chan struct{})
|
||
go func() { a.executeNewTask(lt); close(done) }()
|
||
|
||
select {
|
||
case <-sp.entered:
|
||
case <-time.After(3 * time.Second):
|
||
t.Fatal("provider 第 1 次调用未发生")
|
||
}
|
||
|
||
// 注入 L4 中断(cli)
|
||
intrEvt, _ := textEvent("cli", "紧急打断")
|
||
intrEvt.Payload["interrupt"] = true
|
||
if !a.sched.requestPreempt(intrEvt, LevelCritical) {
|
||
t.Fatal("L4 应请求抢占并返回 true(应取消 LLM)")
|
||
}
|
||
a.cancelCurrentLLM()
|
||
|
||
select {
|
||
case <-done:
|
||
case <-time.After(3 * time.Second):
|
||
t.Fatal("低优先级任务未在取消后挂起")
|
||
}
|
||
|
||
snap := a.DumpScheduler()
|
||
if snap.Running != nil {
|
||
t.Fatal("挂起后不应还有 running")
|
||
}
|
||
if len(snap.SuspendPool) != 1 {
|
||
t.Fatalf("suspendPool=%d,期望 1", len(snap.SuspendPool))
|
||
}
|
||
if len(snap.PendingInterrupts) != 1 {
|
||
t.Fatalf("pendingInterrupts=%d,期望 1", len(snap.PendingInterrupts))
|
||
}
|
||
sf := snap.SuspendPool[0].Frame
|
||
if sf.Terminal != terminalSuspended {
|
||
t.Fatalf("挂起任务终态=%v,期望 terminalSuspended", sf.Terminal)
|
||
}
|
||
if sf.Step != StepLLM {
|
||
t.Fatalf("应在 StepLLM 安全点挂起,实际 step=%v", sf.Step)
|
||
}
|
||
msgsBefore := len(sf.Msgs)
|
||
|
||
// R5:第一次 LLM 调用被丢弃,未产生新消息。
|
||
if sp.callCount() != 1 {
|
||
t.Fatalf("挂起前 LLM 调用=%d,期望 1(不完整请求被丢弃)", sp.callCount())
|
||
}
|
||
|
||
// Q3:三集合统一比较 → 下一轮取中断(L4 > L1)。
|
||
it, _, k := a.sched.nextRef()
|
||
if k != nextPending || it.Level != LevelCritical {
|
||
t.Fatalf("应取到 pending 中断,kind=%v level=%v", k, it.Level)
|
||
}
|
||
// D1=A:抢占式中断任务继承被打断任务的只读前缀。
|
||
if len(it.SeedMsgs) == 0 {
|
||
t.Fatal("抢占式中断任务必须继承只读前缀(D1=A)")
|
||
}
|
||
a.executeNewTask(it)
|
||
if len(a.DumpScheduler().PendingInterrupts) != 0 {
|
||
t.Fatal("中断任务执行后 pendingInterrupts 应清空")
|
||
}
|
||
|
||
// R1:恢复被抢占任务;msgs 与被抢占前逐字节一致(长度不变),并从 S_LLM 重发。
|
||
rt, rf, k2 := a.sched.nextRef()
|
||
if k2 != nextSuspended || rt != lowTask {
|
||
t.Fatalf("应恢复被抢占任务,kind=%v", k2)
|
||
}
|
||
if rf.Step != StepLLM {
|
||
t.Fatalf("恢复游标=%v,期望 StepLLM", rf.Step)
|
||
}
|
||
if len(rf.Msgs) != msgsBefore {
|
||
t.Fatalf("恢复后 msgs 长度=%d,期望 %d(不得被中断污染)", len(rf.Msgs), msgsBefore)
|
||
}
|
||
a.resumeTask(rt, rf)
|
||
|
||
if sp.callCount() != 3 {
|
||
t.Fatalf("LLM 总调用=%d,期望 3(丢弃 1 + 中断 1 + 恢复 1)", sp.callCount())
|
||
}
|
||
snap = a.DumpScheduler()
|
||
if len(snap.SuspendPool) != 0 || snap.Running != nil {
|
||
t.Fatalf("全部结束后应无挂起与运行任务:%+v", snap)
|
||
}
|
||
if snap.Stats.Executed != 2 {
|
||
t.Fatalf("Executed=%d,期望 2(中断任务 + 被抢占任务)", snap.Stats.Executed)
|
||
}
|
||
}
|
||
|
||
// P2/P3:同级与更低级都不得抢占,请求进 pendingInterrupts。
|
||
func TestPreempt_LowerOrEqualDoesNotPreempt(t *testing.T) {
|
||
sp := newPreemptProvider("low-done", "intr-done")
|
||
a := newPreemptAgent(t, sp)
|
||
|
||
lowTask, _ := enqueueTask(t, a, LevelInteractive, "cli", "运行中的 L3")
|
||
if _, _, kind := a.sched.nextRef(); kind != nextReady {
|
||
t.Fatal("应取到运行任务")
|
||
}
|
||
|
||
done := make(chan struct{})
|
||
go func() { a.executeNewTask(lowTask); close(done) }()
|
||
select {
|
||
case <-sp.entered:
|
||
case <-time.After(3 * time.Second):
|
||
t.Fatal("provider 未被调用")
|
||
}
|
||
|
||
// 同级 L3
|
||
e1, _ := textEvent("webui", "同级打断")
|
||
if a.sched.requestPreempt(e1, LevelInteractive) {
|
||
t.Fatal("同级不得抢占")
|
||
}
|
||
// 更低级 L1
|
||
e2, _ := textEvent("system", "低优先级打断")
|
||
if a.sched.requestPreempt(e2, LevelBackground) {
|
||
t.Fatal("更低级不得抢占")
|
||
}
|
||
if a.sched.preemptGrantedFor() {
|
||
t.Fatal("未 arm 让位信号,preemptGrantedFor 应为 false")
|
||
}
|
||
|
||
// 运行任务没有被取消,仍在等它自己的 ctx;用取消让它收尾,便于清理。
|
||
a.cancelCurrentLLM()
|
||
select {
|
||
case <-done:
|
||
case <-time.After(3 * time.Second):
|
||
t.Fatal("运行任务未结束")
|
||
}
|
||
if len(a.DumpScheduler().PendingInterrupts) != 2 {
|
||
t.Fatalf("两条未抢占中断都应保留在 pendingInterrupts,实际 %d",
|
||
len(a.DumpScheduler().PendingInterrupts))
|
||
}
|
||
}
|
||
|
||
// D1T:suspendPool 满时不再下潜(canSuspend=false),让位信号也不会 arm。
|
||
func TestPreempt_DepthCapBlocksSuspension(t *testing.T) {
|
||
a := newPreemptAgent(t, newPreemptProvider())
|
||
|
||
if a.sched.maxSuspendDepth != 4 {
|
||
t.Fatalf("默认深度上限=%d,期望 4", a.sched.maxSuspendDepth)
|
||
}
|
||
frame := func() *TaskFrame { return a.newTaskFrame("x", a.stageCtxFromInput("x", "", "")) }
|
||
for i := 0; i < a.sched.maxSuspendDepth; i++ {
|
||
a.sched.suspend(&Task{ID: uint64(i + 1), Level: LevelBackground}, frame())
|
||
}
|
||
if a.sched.canSuspend() {
|
||
t.Fatal("深度已达上限,canSuspend 应为 false")
|
||
}
|
||
// 超限兜底:仍保留帧(不丢副作用记录),但计数 Rejected。
|
||
before := a.DumpScheduler().Stats.Rejected
|
||
a.sched.suspend(&Task{ID: 99, Level: LevelBackground}, frame())
|
||
if a.DumpScheduler().Stats.Rejected != before+1 {
|
||
t.Fatal("超限挂起必须计数 Rejected")
|
||
}
|
||
if len(a.DumpScheduler().SuspendPool) != a.sched.maxSuspendDepth+1 {
|
||
t.Fatal("兜底路径必须保留帧而不是丢弃")
|
||
}
|
||
}
|
||
|
||
// 中断不丢:空闲时请求抢占 → 不 arm 信号,但请求进 pendingInterrupts 并被选出。
|
||
func TestPreempt_IdleInterruptIsQueuedNotLost(t *testing.T) {
|
||
a := newPreemptAgent(t, &scriptProvider{script: []*agentAPI.CompletionResponse{{Content: "已处理中断"}}})
|
||
|
||
evt, respCh := textEvent("qq", "空闲时的中断")
|
||
if a.sched.requestPreempt(evt, LevelMessage) {
|
||
t.Fatal("空闲时不应请求取消 LLM(没有运行任务)")
|
||
}
|
||
if len(a.DumpScheduler().PendingInterrupts) != 1 {
|
||
t.Fatal("空闲时的中断必须进 pendingInterrupts")
|
||
}
|
||
task, _, kind := a.sched.nextRef()
|
||
if kind != nextPending || task.Level != LevelMessage {
|
||
t.Fatalf("应取到待处理中断,kind=%v", kind)
|
||
}
|
||
a.executeNewTask(task)
|
||
if len(respCh) != 1 {
|
||
t.Fatal("中断任务应完成并回执")
|
||
}
|
||
}
|
||
|
||
// 带媒体/中断标记的输入走新任务路径时不得污染下一个任务的尾部消息。
|
||
func TestPreempt_SeedPathDoesNotLeakInterruptFlag(t *testing.T) {
|
||
sp := &scriptProvider{script: []*agentAPI.CompletionResponse{{Content: "ok"}}}
|
||
a := newPreemptAgent(t, sp)
|
||
|
||
f := a.newTaskFrame("打断文本", a.stageCtxFromInput("打断文本", "", ""))
|
||
f.SeedMsgs = []agentAPI.Message{{Role: "system", Content: "S"}, {Role: "user", Content: "U"}}
|
||
a.interruptInput = true
|
||
if out := a.stepPrepare(f); out != outcomeContinue {
|
||
t.Fatalf("seed 路径应继续,实际 %v", out)
|
||
}
|
||
if a.interruptInput {
|
||
t.Fatal("seed 路径必须消费 interruptInput,否则下一个任务尾部会被误改")
|
||
}
|
||
last := f.Msgs[len(f.Msgs)-1]
|
||
if last.Role != "user" || last.Content != "打断文本" {
|
||
t.Fatalf("seed 路径尾部应为中断输入本身,实际 %+v", last)
|
||
}
|
||
if f.Step != StepLLM {
|
||
t.Fatalf("seed 路径应直接进入 StepLLM,实际 %v", f.Step)
|
||
}
|
||
}
|