feat(hitl): 增量3a —— resume 重入闭环(从 checkpoint 续跑审批)

把 HITL 的恢复半边补上:人工决定到达后从 checkpoint 重入图、喂给审批节点续跑。
至此中断→恢复全闭环在 compose 路径打通(in-process 端到端测试钉死)。

- runComposeGraph 重构为统一入口 execComposeGraph(rc):rc==nil 全新执行、rc!=nil
  从 checkpoint resume,两路共用建图/编译,仅三处分叉——①记忆注入仅 fresh(resume
  时黑板由 checkpoint 还原,重注入会覆盖已积累态);②Invoke ctx(resume 经
  ResumeWithData 注入决定);③终态黑板来源。
- live 捕获:resume 时 compose 用 checkpoint 还原的实例作 local state(非闭包 b),
  故节点 ProcessState 内捕获 live 指针,Invoke 后据此读终态(fresh 仍读 b)。
- resume 记录:中断时把 {interruptID, Task} 落 KV(pending:task_id),供决定在另一
  goroutine/重启进程独立重建任务并续跑;终态清记录 + checkpoint(幂等)。
- ResumeApproval(t, dec) 入口:载记录定位中断点 → execComposeGraph resume。续跑
  语义同 fresh:再遇审批→errInterrupted、批准跑完→稿+refs、拒绝→errRejected。

测试:批准(黑板无损还原 + 下游执行 + 状态拨回 running + 清记录/checkpoint)、
拒绝(errRejected + 拒绝语 + 下游不跑 + 清 checkpoint)。这条用例也透过 eino 真实
checkpoint 路径端到端验证了 board 序列化(2a)。全量 go test ./... 绿。

下一步 3b:审批决定改 JetStream 持久投递 + orchestrator 持久消费者触发 ResumeApproval
+ main 接 Bus.Checkpoints 打开开关。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-06-29 12:57:07 +08:00
parent bc7600625d
commit 368938ce89
2 changed files with 225 additions and 29 deletions
@@ -115,6 +115,82 @@ func TestComposeApprovalInterruptCheckpoints(t *testing.T) {
}
}
// approvalGraph 是 input→approval→agent 的最小 HITL 图,agent 用 echoLLM 回 "ANS:<query>"
// 借此判断审批放行后下游是否真的执行。
const approvalGraph = `{"version":"1","nodes":[
{"id":"in","kind":"input","config":{"text":"写上线报告"}},
{"id":"ap","kind":"approval","config":{"title":"上线审批"}},
{"id":"a","kind":"agent","config":{"system":"报告助手"}}
],"edges":[
{"source":"in","target":"ap"},{"source":"ap","target":"a"}
]}`
// interruptThenSetup 跑一次 fresh,断言中断并落了 resume 记录,返回可继续 resume 的编排器与任务。
func interruptThenSetup(t *testing.T, kv *memKV, st *fakeStatus) (*Orchestrator, *contract.Task) {
t.Helper()
o := &Orchestrator{pool: echoLLM(), breaker: harness.NewCircuitBreaker(), sink: &fakeSink{}, status: st}
o.SetCheckpoints(kv)
task := &contract.Task{ID: "t_resume", Graph: []byte(approvalGraph)}
if _, _, err := o.runComposeGraph(context.Background(), task, &execTracer{}); !errors.Is(err, errInterrupted) {
t.Fatalf("fresh 跑应中断,got %v", err)
}
if _, ok, _ := kv.Get(context.Background(), pendingKey(task.ID)); !ok {
t.Fatalf("中断应落 resume 记录(pending:%s)", task.ID)
}
return o, task
}
// TestComposeApprovalResumeApprove 钉死 HITL 恢复半边——批准:从 checkpoint 重入图,黑板无损还原,
// 下游 agent 执行出稿,终态清 resume 记录 + checkpoint。这条用例也透过 eino 真实 checkpoint 路径
// 验证了 board 序列化(增量2a)端到端可用。
func TestComposeApprovalResumeApprove(t *testing.T) {
kv, st := newMemKV(), &fakeStatus{}
o, task := interruptThenSetup(t, kv, st)
dec := &contract.ApprovalDecision{TaskID: task.ID, Approved: true, Note: "放行"}
ans, _, err := o.ResumeApproval(context.Background(), task, dec, &execTracer{})
if err != nil {
t.Fatalf("批准 resume 不应报错: %v", err)
}
if !strings.Contains(ans, "ANS:") {
t.Fatalf("批准后下游 agent 应执行并出稿,got %q", ans)
}
if st.last() != contract.TaskRunning {
t.Fatalf("批准应把状态从 waiting 拨回 runninggot %q", st.last())
}
if _, ok, _ := kv.Get(context.Background(), pendingKey(task.ID)); ok {
t.Fatalf("成功收尾应清 resume 记录")
}
if _, ok, _ := kv.Get(context.Background(), task.ID); ok {
t.Fatalf("成功收尾应清 compose checkpoint")
}
}
// TestComposeApprovalResumeReject 钉死 HITL 恢复半边——拒绝:resume 置 rejected、返回 errRejected、
// 出拒绝语、下游 agent 不执行、清 checkpoint。
func TestComposeApprovalResumeReject(t *testing.T) {
kv, st := newMemKV(), &fakeStatus{}
o, task := interruptThenSetup(t, kv, st)
dec := &contract.ApprovalDecision{TaskID: task.ID, Approved: false, Note: "不批"}
ans, refs, err := o.ResumeApproval(context.Background(), task, dec, &execTracer{})
if !errors.Is(err, errRejected) {
t.Fatalf("拒绝 resume 应返回 errRejectedgot %v", err)
}
if !strings.Contains(ans, "已被拒绝") {
t.Fatalf("应出拒绝语,got %q", ans)
}
if strings.Contains(ans, "ANS:") {
t.Fatalf("拒绝后下游 agent 不应执行,got %q", ans)
}
if refs != nil {
t.Fatalf("拒绝终态 refs 应为 nilgot %v", refs)
}
if _, ok, _ := kv.Get(context.Background(), task.ID); ok {
t.Fatalf("拒绝收尾应清 compose checkpoint")
}
}
// TestComposeReturnsRefs 钉死 compose 路径回传检索来源——曾被硬写成 nil,导致忠实度评测静默失效。
// 与 runGraph 同图对照:两路径都应回流含检索片段的 refs(喂 grounded judge)。
func TestComposeReturnsRefs(t *testing.T) {