feat(scheduler): M3a+M3b 任务生命周期重构 + 四级优先级抢占

设计依据 docs/zh/input-scheduler-design.md §3–§8、§14。

M3a(行为等价的所有权重构):
- processInput 拆为 prepareInputTask / runTaskSteps / finishInputTask,
  帧覆盖 prepare→step…→finish;提交与回执只在 finish 段发生一次,
  为安全点挂起做准备(挂起不重复提交)
- process() 不再持 a.mu(挂起不能持锁),a.mu 字段随之移除
- TaskFrame 增加任务层现场(Evt/CleanInput/IsInterrupt/StartedAt/Terminal/
  Level/SeedMsgs)与 taskTerminal / outcomeSuspended
- 新增 task_lifecycle_test.go 5 项:正常恰好一次终态、去重 skipped、
  on_input 短路、错误终态、consolidation 路由

M3b(优先级与抢占):
- interceptLoop 重写:只做「收中断 → 定级 → requestPreempt → 必要时取消
  LLM」,绝不触碰帧(不变量 I2);三条降级路径与 interceptCh 兜底退场,
  改为统一的 pendingInterrupts
- scheduler:pendingInterrupts / suspendPool / 让位信号,nextRef 在三集合上
  按统一排序键取值;深度上限 4(canSuspend 在安全点拦下)
- 抢占判据 incoming.level > running.level;相等与更低只入队
- 安全点只在 step 之间;执行中的 step(工具 RPC/ONNX/CAS)天然不可抢占;
  _consolidation_ 整任务视为临界区
- 恢复走 resumeTask:从 frame.Step 继续,不重跑 prepare
- D1=A:suspend 把被打断任务的只读前缀交给抢占比它的中断任务(SeedMsgs)
- 新增 scheduler_preempt_test.go 5 项:抢占-挂起-恢复(含 R1/R5)、同级更低
  不抢占、深度上限、空闲中断不丢、seed 路径不污染标志位

验收:agent 全量 + -race 通过;全仓 build/vet 通过
This commit is contained in:
JianFeeeee
2026-09-13 00:25:13 +08:00
parent 91782685cf
commit a77e1aacae
8 changed files with 1122 additions and 229 deletions

View File

@ -0,0 +1,297 @@
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(LevelInteractive) {
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)
}
}