feat(hitl): 增量3b —— 持久决定投递 + 生产激活(HITL 中断/恢复上线)
把中断/恢复模型在 dispatcher 接线启用,并让审批决定持久化抗离线。至此 HITL 从「阻塞 goroutine 等 5min、core NATS 非持久、不抗重启」升级为「持久化中断 + 决定持久投递 + 从 checkpoint 恢复续跑」。 - contract: 新增审批决定流 StreamApprovals(SUNDYNIX_APPROVALS)/通配 SubjectApprovalAll/消费者 ConsumerApprovals + checkpoint 桶 BucketCheckpoints。 - bus: EnsureApprovalStream(JetStream 流持久捕获 sundynix.approval.>,MaxAge 24h; gateway 现有 nc.Publish 的决定被本流自动捕获,无需改 gateway)+ ConsumeApprovals (持久消费者,队列组多副本安全)。 - orchestrator: HandleApprovalDecision(据 task_id 取 resume 记录续跑;无记录则忽略, 兼容阻塞态任务的决定 + 决定重投幂等)+ finishResumed(收尾对齐 Handle 尾段: 中断/拒绝/预算/失败/成功+评测落历史)。 - main: 开 checkpoint 存储 + 审批流 + 起决定消费者 → SetCheckpoints 启用中断模型; 任一步失败优雅降级回阻塞模型;停机 drain 在途 resume。 决定经 JetStream 持久:dispatcher 在决定发出时离线,重连后仍消费到并续跑;任一 dispatcher 副本都能据共享 KV 的 checkpoint+记录恢复(HA)。 测试:决定驱动恢复(续跑下游+判 done+清记录)、无记录忽略(幂等/兼容)。 全模块 go build + go test 绿。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"sync"
|
||||
|
||||
"github.com/cloudwego/eino/compose"
|
||||
@@ -430,3 +431,69 @@ func (o *Orchestrator) ResumeApproval(ctx context.Context, t *contract.Task, dec
|
||||
}
|
||||
return o.execComposeGraph(ctx, t, tr, &resumeCtx{interruptID: p.InterruptID, dec: dec})
|
||||
}
|
||||
|
||||
// HandleApprovalDecision 是审批决定持久消费者的回调:据 task_id 取 resume 记录续跑并收尾。
|
||||
// 无 resume 记录(阻塞态任务的决定 / 已恢复 / 已过期)则忽略——本消费者只管中断/恢复模型。
|
||||
// 幂等:决定重投时 resume 记录已被成功收尾清掉 → loadResume 落空 → 安全跳过。
|
||||
func (o *Orchestrator) HandleApprovalDecision(ctx context.Context, dec *contract.ApprovalDecision) {
|
||||
if o.checkpoints == nil || dec == nil || dec.TaskID == "" {
|
||||
return
|
||||
}
|
||||
p, err := o.loadResume(ctx, dec.TaskID)
|
||||
if errors.Is(err, errNoPending) {
|
||||
return // 非中断态任务(阻塞模型自己经 WaitApproval 收)/ 已处理过
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("[eino] 取 resume 记录失败 task=%s: %v", dec.TaskID, err)
|
||||
return
|
||||
}
|
||||
var t contract.Task
|
||||
if uerr := json.Unmarshal(p.Task, &t); uerr != nil || t.ID == "" {
|
||||
log.Printf("[eino] resume 记录损坏 task=%s: %v", dec.TaskID, uerr)
|
||||
o.clearResume(ctx, dec.TaskID) // 坏记录清掉,避免决定无限重投
|
||||
return
|
||||
}
|
||||
tr := o.tracer(t.ID)
|
||||
defer tr.done()
|
||||
answer, refs, rerr := o.ResumeApproval(ctx, &t, dec, tr)
|
||||
o.finishResumed(ctx, &t, answer, refs, rerr)
|
||||
}
|
||||
|
||||
// finishResumed 给 resume 续跑收尾,语义对齐 Handle 尾段(中断/拒绝/预算/失败/成功)。
|
||||
func (o *Orchestrator) finishResumed(ctx context.Context, t *contract.Task, answer string, refs []string, err error) {
|
||||
switch {
|
||||
case errors.Is(err, errInterrupted):
|
||||
return // 续跑中又遇审批:保持 waiting,等下一个决定(resume 记录已重新落盘)
|
||||
case errors.Is(err, errRejected):
|
||||
if answer != "" {
|
||||
_ = o.sink.PublishToken(t.ID, []byte(answer))
|
||||
}
|
||||
_ = o.sink.CompleteStream(t.ID)
|
||||
o.breaker.Report(true) // 拒绝是人为决策,非后端故障
|
||||
o.setStatus(t.ID, contract.TaskRejected, truncate(answer, 120))
|
||||
return
|
||||
case errors.Is(err, errBudget):
|
||||
if answer != "" {
|
||||
_ = o.sink.PublishToken(t.ID, []byte(answer))
|
||||
}
|
||||
_ = o.sink.PublishToken(t.ID, []byte("\n\n⚠️ 已达单任务 token 预算上限,自动中止。"))
|
||||
_ = o.sink.CompleteStream(t.ID)
|
||||
o.breaker.Report(true)
|
||||
o.setStatus(t.ID, contract.TaskFailed, "token 预算超限")
|
||||
return
|
||||
case err != nil:
|
||||
_ = o.sink.CompleteStream(t.ID)
|
||||
o.breaker.Report(false)
|
||||
o.finishStatus(t.ID, err)
|
||||
return
|
||||
}
|
||||
// 成功:收尾流 + 判 done + 评测(低分纠偏)+ 落历史(同 Handle)。
|
||||
_ = o.sink.CompleteStream(t.ID)
|
||||
o.breaker.Report(true)
|
||||
o.finishStatus(t.ID, nil)
|
||||
go func() {
|
||||
query := dsl.Compile(t.Graph).Query
|
||||
final := o.evaluate(t, query, answer, refs)
|
||||
o.memorize(t, final)
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -191,6 +191,51 @@ func TestComposeApprovalResumeReject(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleApprovalDecisionResumes 钉死 3b 决定驱动恢复:审批决定到达 → 据 resume 记录续跑 →
|
||||
// 收尾流 + 判 done + 清记录。这是「持久消费者收决定 → ResumeApproval → finishResumed」的闭环。
|
||||
func TestHandleApprovalDecisionResumes(t *testing.T) {
|
||||
kv, st, fs := newMemKV(), &fakeStatus{}, &fakeSink{}
|
||||
o := &Orchestrator{pool: echoLLM(), breaker: harness.NewCircuitBreaker(), sink: fs, status: st}
|
||||
o.SetCheckpoints(kv)
|
||||
task := &contract.Task{ID: "t_dec", Graph: []byte(approvalGraph)}
|
||||
if _, _, err := o.runComposeGraph(context.Background(), task, &execTracer{}); !errors.Is(err, errInterrupted) {
|
||||
t.Fatalf("fresh 跑应中断,got %v", err)
|
||||
}
|
||||
|
||||
// 决定到达(批准)→ 消费者回调驱动续跑 + 收尾。
|
||||
o.HandleApprovalDecision(context.Background(), &contract.ApprovalDecision{TaskID: task.ID, Approved: true})
|
||||
|
||||
if !fs.done {
|
||||
t.Fatalf("续跑成功应收尾 Token 流")
|
||||
}
|
||||
if !strings.Contains(fs.text(), "ANS:") {
|
||||
t.Fatalf("批准后应续跑下游 agent,sink=%q", fs.text())
|
||||
}
|
||||
if st.last() != contract.TaskDone {
|
||||
t.Fatalf("续跑成功应判 done,got %q", st.last())
|
||||
}
|
||||
if _, ok, _ := kv.Get(context.Background(), pendingKey(task.ID)); ok {
|
||||
t.Fatalf("成功收尾应清 resume 记录")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleApprovalDecisionNoPendingIgnored 钉死幂等/兼容:无 resume 记录(阻塞态任务的决定 /
|
||||
// 决定重投)→ 直接忽略,不收尾、不改状态。
|
||||
func TestHandleApprovalDecisionNoPendingIgnored(t *testing.T) {
|
||||
kv, st, fs := newMemKV(), &fakeStatus{}, &fakeSink{}
|
||||
o := &Orchestrator{pool: echoLLM(), breaker: harness.NewCircuitBreaker(), sink: fs, status: st}
|
||||
o.SetCheckpoints(kv)
|
||||
|
||||
o.HandleApprovalDecision(context.Background(), &contract.ApprovalDecision{TaskID: "never_interrupted", Approved: true})
|
||||
|
||||
if fs.done || fs.text() != "" {
|
||||
t.Fatalf("无 resume 记录不应收尾或产出,sink done=%v text=%q", fs.done, fs.text())
|
||||
}
|
||||
if seq, _ := st.snapshot(); len(seq) != 0 {
|
||||
t.Fatalf("无 resume 记录不应改状态,got %v", seq)
|
||||
}
|
||||
}
|
||||
|
||||
// TestComposeReturnsRefs 钉死 compose 路径回传检索来源——曾被硬写成 nil,导致忠实度评测静默失效。
|
||||
// 与 runGraph 同图对照:两路径都应回流含检索片段的 refs(喂 grounded judge)。
|
||||
func TestComposeReturnsRefs(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user