Files
HomeAgent/internal/agent/core/task_test.go
JianFeeeee c69a1f11af 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 通过
2026-09-13 00:25:52 +08:00

216 lines
7.9 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
// M1 验收测试:状态机与 TaskFrame 的**行为等价性**。
//
// 设计依据 docs/zh/input-scheduler-design.md §11R3 / X3 的 M1 形态):
// M1 不引入抢占,因此 R3 退化为「经状态机跑出的结果与脚本预期一致」;
// X3 在 M1 退化为「驱动循环必然以一次终态返回结束(不空转、不超步数)」。
//
// 抢占/挂起/恢复/优先级在 M3 起才有测试。
import (
"context"
"errors"
"testing"
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
)
// scriptProvider 按脚本依次返回 CompletionResponse。
//
// ChatStream 故意返回错误:驱动 chatStreamWithFallback 走非流式回退,
// 这样脚本就是「第 N 次调用返回第 N 个响应」,不依赖流式分片语义。
type scriptProvider struct {
script []*agentAPI.CompletionResponse
idx int
reqs []*agentAPI.CompletionRequest
// err 非空时 Chat 直接返回它(用于错误路径测试)。
// 配合 ProviderError(401) 可跳过 2s 瞬时重试,让测试保持快速。
err error
}
func (s *scriptProvider) Name() string { return "script" }
func (s *scriptProvider) Chat(ctx context.Context, req *agentAPI.CompletionRequest) (*agentAPI.CompletionResponse, error) {
s.reqs = append(s.reqs, req)
if s.err != nil {
return nil, s.err
}
if s.idx >= len(s.script) {
return &agentAPI.CompletionResponse{Content: ""}, nil
}
r := s.script[s.idx]
s.idx++
return r, nil
}
func (s *scriptProvider) ChatStream(ctx context.Context, req *agentAPI.CompletionRequest) (<-chan agentAPI.StreamChunk, error) {
return nil, errors.New("script provider: streaming disabled")
}
func (s *scriptProvider) MaxContextTokens() int { return 8192 }
func newTaskTestAgent(t *testing.T, sp agentAPI.Provider, sh *StageHost) *Agent {
t.Helper()
return New(AgentConfig{
ID: "task-test",
Provider: sp,
ProviderManager: agentAPI.NewProviderManager(),
StageHost: sh,
IO: agentIO.NewIOManager(),
})
}
func tc(id, name string) agentAPI.ToolCall {
return agentAPI.ToolCall{ID: id, Name: name, Arguments: map[string]interface{}{"q": id}}
}
// R3M1 形态):一次工具轮 + 一次收尾轮,结果与工具调用计数必须正确。
func TestTaskFrame_R3_ToolRoundTrip(t *testing.T) {
sh := NewStageHost()
var got []string
sh.RegisterTool("t_echo", sdk.ToolDef{Name: "t_echo", Plugin: "t"}, func(args map[string]interface{}) (interface{}, error) {
got = append(got, args["q"].(string))
return "OUT", nil
})
sp := &scriptProvider{script: []*agentAPI.CompletionResponse{
{Content: "让我调用工具", ToolCalls: []agentAPI.ToolCall{tc("c1", "t_echo")}},
{Content: "最终答复"},
}}
a := newTaskTestAgent(t, sp, sh)
stageCtx := a.stageCtxFromInput("你好", "", "")
resp, toolsUsed, toolResults, err := a.process("你好", stageCtx)
if err != nil {
t.Fatalf("process 返回错误: %v", err)
}
if resp != "最终答复" {
t.Fatalf("响应=%q期望 %q", resp, "最终答复")
}
if len(toolsUsed) != 1 || toolsUsed[0] != "t_echo" {
t.Fatalf("toolsUsed=%v期望恰好一次 t_echo", toolsUsed)
}
if len(toolResults) != 1 || toolResults[0].Name != "t_echo" || toolResults[0].Output != "OUT" {
t.Fatalf("toolResults=%+v期望一条 t_echo/OUT", toolResults)
}
if len(got) != 1 || got[0] != "c1" {
t.Fatalf("工具实参=%v期望恰好执行一次且参数来自脚本", got)
}
if len(sp.reqs) != 2 {
t.Fatalf("LLM 调用次数=%d期望 2工具轮 + 收尾轮)", len(sp.reqs))
}
// 第二轮请求必须携带 assistant(tool_call) + tool 结果两条消息。
msgs := sp.reqs[1].Messages
var hasAssistantCall, hasToolResult bool
for _, m := range msgs {
if m.Role == "assistant" && len(m.ToolCalls) == 1 && m.ToolCalls[0].ID == "c1" {
hasAssistantCall = true
}
if m.Role == "tool" && m.ToolCallID == "c1" && m.Content == "OUT" {
hasToolResult = true
}
}
if !hasAssistantCall || !hasToolResult {
t.Fatalf("第二轮请求缺少工具调用配对assistant=%v tool=%v", hasAssistantCall, hasToolResult)
}
}
// X3M1 形态):多轮脚本必须在有限步内以一次终态返回结束。
func TestTaskFrame_X3_TerminatesWithinBudget(t *testing.T) {
sh := NewStageHost()
sh.RegisterTool("t_noop", sdk.ToolDef{Name: "t_noop", Plugin: "t"}, func(args map[string]interface{}) (interface{}, error) {
return "ok", nil
})
// 3 个工具轮 + 收尾轮:状态机会在 StepLLM/StepToolBegin/.../StepTurnEnd 间往返 4 次。
var script []*agentAPI.CompletionResponse
for i := 0; i < 3; i++ {
script = append(script, &agentAPI.CompletionResponse{
Content: "round",
ToolCalls: []agentAPI.ToolCall{tc("c"+string(rune('a'+i)), "t_noop")},
})
}
script = append(script, &agentAPI.CompletionResponse{Content: "done"})
sp := &scriptProvider{script: script}
a := newTaskTestAgent(t, sp, sh)
resp, toolsUsed, toolResults, err := a.process("跑三轮", a.stageCtxFromInput("跑三轮", "", ""))
if err != nil {
t.Fatalf("process 返回错误: %v", err)
}
if resp != "done" {
t.Fatalf("响应=%q期望 done", resp)
}
if len(toolsUsed) != 3 || len(toolResults) != 3 {
t.Fatalf("toolsUsed=%d toolResults=%d期望各 3", len(toolsUsed), len(toolResults))
}
// 步数护栏未触发(触发了会是 "step budget exhausted" 错误)。
if len(sp.reqs) != 4 {
t.Fatalf("LLM 调用次数=%d期望 4", len(sp.reqs))
}
}
// 中断interceptCh在工具批中途到达时本批**剩余工具被放弃**,直接进入下一轮。
//
// 这是原实现的 `break` 语义process.go 旧版工具循环尾部),必须保持。
func TestTaskFrame_InterruptAbandonsRemainingBatch(t *testing.T) {
sh := NewStageHost()
var a *Agent
sh.RegisterTool("t_first", sdk.ToolDef{Name: "t_first", Plugin: "t"}, func(args map[string]interface{}) (interface{}, error) {
// 工具执行期间产生一次中断(模拟插件在工具里注入打断)。
a.interceptCh <- &agentIO.InputEvent{Source: "t", Payload: map[string]interface{}{"content": "新的用户输入"}}
return "first-out", nil
})
sh.RegisterTool("t_second", sdk.ToolDef{Name: "t_second", Plugin: "t"}, func(args map[string]interface{}) (interface{}, error) {
t.Fatal("批内第二个工具不应被执行:中断必须放弃剩余批次")
return nil, nil
})
sp := &scriptProvider{script: []*agentAPI.CompletionResponse{
{Content: "", ToolCalls: []agentAPI.ToolCall{tc("c1", "t_first"), tc("c2", "t_second")}},
{Content: "处理完中断后的答复"},
}}
a = newTaskTestAgent(t, sp, sh)
resp, toolsUsed, toolResults, err := a.process("开始", a.stageCtxFromInput("开始", "", ""))
if err != nil {
t.Fatalf("process 返回错误: %v", err)
}
if resp != "处理完中断后的答复" {
t.Fatalf("响应=%q", resp)
}
if len(toolsUsed) != 1 || toolsUsed[0] != "t_first" {
t.Fatalf("toolsUsed=%v期望只有 t_first", toolsUsed)
}
if len(toolResults) != 1 || toolResults[0].Output != "first-out" {
t.Fatalf("toolResults=%+v期望只有 t_first 的结果", toolResults)
}
// 第二轮请求里必须出现被打断内容system 角色、[中断消息] 前缀)。
found := false
for _, m := range sp.reqs[1].Messages {
if m.Role == "system" && len(m.Content) > 0 && m.Content[0:len("[中断消息]")] == "[中断消息]" {
found = true
}
}
if !found {
t.Fatalf("第二轮请求缺少 [中断消息] system 消息")
}
}
// 状态机对未知 step 必须失败退出而不是空转。
func TestTaskFrame_UnknownStepFails(t *testing.T) {
sp := &scriptProvider{}
a := newTaskTestAgent(t, sp, NewStageHost())
f := a.newTaskFrame("x", a.stageCtxFromInput("x", "", ""))
f.Step = Step(999)
if out := a.step(f); out != outcomeFailed {
t.Fatalf("未知 step 应返回 outcomeFailed实际 %v", out)
}
if f.Err == nil {
t.Fatal("未知 step 必须带错误信息")
}
}