feat(hitl): 增量2b —— 审批节点改 compose.Interrupt + orchestrator 识别中断

接了 checkpoint 后端时,审批节点从「阻塞 goroutine 等 5min」改为持久化中断:
首次执行发待审 + 置 waiting + compose.Interrupt → compose 把整图状态(含 board)
落进 checkpoint store 并返回中断错误 → Handle 释放 goroutine、任务停在 waiting,
不收尾不评测不判 done。抗 dispatcher 重启。

- orchestrator: 新增 errInterrupted 哨兵 + checkpoints 字段 + SetCheckpoints
  setter(沿用 guard/usageSink 的 setter 注入,不动构造签名);Handle 识别
  errInterrupted → 释放 goroutine、保留 waiting、SSE 流不关。
- compose_compiler: 编译挂 WithCheckPointStore + WithGraphName("root"),Invoke
  带 WithCheckPointID(task_id);审批节点接 checkpoint 时改走专用
  approvalInterruptLambda(须把中断错误作节点返回值上抛,泛型 lambda 会吞掉);
  ExtractInterruptInfo 识别中断 → 上抛 errInterrupted。
- graph.go: 抽出 approvalSummary / applyApprovalDecision,阻塞式与中断式审批共用,
  杜绝两路文案/语义漂移。

双路径并存:未接 checkpoint 后端(store=nil)维持阻塞模型,行为不变;main 暂不
接,生产保持阻塞,待增量3 resume 闭环补齐再打开(建在 flag 后)。

测试:中断半边端到端——errInterrupted + 置 waiting + checkpoint 落 KV(key=task_id)
+ 下游 agent 不执行。既有阻塞式审批/等价测试全绿。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-06-29 12:46:48 +08:00
parent 5a1a994cc2
commit bc7600625d
4 changed files with 176 additions and 32 deletions
@@ -102,6 +102,13 @@ func (o *Orchestrator) runComposeGraph(ctx context.Context, t *contract.Task, tr
func(context.Context, flowSignal) (flowSignal, error) { return flowSignal{}, nil }))
continue
}
// 接了 checkpoint 后端 → 审批走中断式(compose.Interrupt 落盘释放 goroutine、抗重启),
// 用专用 lambda(须把中断错误作为节点返回值上抛,泛型 lambda 会吞掉它)。
// 未接后端 → 落入下方泛型 lambda 经 execDSLNode 走阻塞式 approvalNode(行为不变)。
if node.Kind == "approval" && o.checkpoints != nil {
_ = g.AddLambdaNode(key(node.ID), compose.InvokableLambda(o.approvalInterruptLambda(t, node, tr)))
continue
}
_ = g.AddLambdaNode(key(node.ID), compose.InvokableLambda(
func(c context.Context, _ flowSignal) (flowSignal, error) {
perr := compose.ProcessState(c, func(sc context.Context, bd *board) error {
@@ -165,14 +172,37 @@ func (o *Orchestrator) runComposeGraph(ctx context.Context, t *contract.Task, tr
}
}
// 4) 编译(DAG 模式:无依赖节点并行调度)。编译失败 → 降级回自研 graph.go(安全网)
r, cerr := g.Compile(ctx, compose.WithNodeTriggerMode(compose.AllPredecessor))
// 4) 编译(DAG 模式:无依赖节点并行调度)。接了 checkpoint 后端则挂上,使审批中断可落盘恢复
// 编译失败 → 降级回自研 graph.go(安全网)。
compileOpts := []compose.GraphCompileOption{
compose.WithNodeTriggerMode(compose.AllPredecessor),
compose.WithGraphName("root"),
}
if o.checkpoints != nil {
compileOpts = append(compileOpts, compose.WithCheckPointStore(newCheckpointStore(o.checkpoints)))
}
r, cerr := g.Compile(ctx, compileOpts...)
if cerr != nil {
tr.info("task", "system", "compose 编译失败", "退回自研 graph.go"+cerr.Error())
return o.runGraph(ctx, t, tr) // 降级回权威实现(带 refs / 终态)
}
if _, ierr := r.Invoke(ctx, flowSignal{}); ierr != nil {
tr.info("task", "system", "compose 执行告警", ierr.Error()) // 副作用已落 board;下方按终态收尾
// checkpoint id = task id:审批中断时 compose 据此把整图状态(含 board)落进 store。
var invokeOpts []compose.Option
if o.checkpoints != nil {
invokeOpts = append(invokeOpts, compose.WithCheckPointID(t.ID))
}
if _, ierr := r.Invoke(ctx, flowSignal{}, invokeOpts...); ierr != nil {
// HITL 审批中断:checkpoint 已落、任务停在 waiting(审批 lambda 内已置)→ 上抛哨兵,
// Handle 据此释放 goroutine 而不收尾,等决定到达后 resume(增量3)。
if info, ok := compose.ExtractInterruptInfo(ierr); ok {
id := ""
if len(info.InterruptContexts) > 0 {
id = info.InterruptContexts[0].ID
}
tr.info("task", "approval", "已中断等待审批", "checkpoint 已落,释放执行;interrupt="+id)
return b.answer, nil, errInterrupted
}
tr.info("task", "system", "compose 执行告警", ierr.Error()) // 非中断的执行告警:副作用已落 board,下方按终态收尾
}
// 终态对齐 graph.go:审批拒绝 / 预算触顶 / 模型失败要显式上抛,否则任务误判 done-空。
@@ -227,8 +257,9 @@ func (o *Orchestrator) execDSLNode(ctx context.Context, t *contract.Task, n dsl.
b.refs, b.toolOut = merged, nil
tr.info("aggregate:"+n.ID, "system", labelOf(n, "汇聚"), "策略:"+firstNonEmpty(cstr(n.Config, "strategy"), "拼接"))
case "approval":
// HITL 人工审批中断:阻塞等批准/拒绝,拒绝/超时置 b.rejected(节点入口守卫据此中止下游)。
// 返回的放行下游」清单在 compose 由静态边 + rejected 守卫接管,这里丢弃。
// 阻塞式审批(未接 checkpoint 后端时走这里):阻塞等批准/拒绝,拒绝/超时置 b.rejected
// (节点入口守卫据此中止下游)。返回的放行清单由静态边 + rejected 守卫接管,丢弃。
// 接了 checkpoint 后端时,审批已在建图阶段改走 approvalInterruptLambda,不会到这。
o.approvalNode(ctx, t.ID, n, b, tr, nil)
case "render":
o.renderNode(ctx, t.ID, n, b, tr)
@@ -240,3 +271,42 @@ func (o *Orchestrator) execDSLNode(ctx context.Context, t *contract.Task, n dsl.
tr.info(n.Kind+":"+n.ID, "system", labelOf(n, n.Kind), "未识别节点,跳过")
}
}
// approvalInterruptLambda 是中断式 HITL 审批节点体(compose checkpoint):
// - 首次执行 → 发待审事件 + 置 waiting + compose.Interruptcompose 把整图状态(含 board
// 经 *board 的自定义 JSON 序列化)落进 checkpoint store 并返回中断错误,
// runComposeGraph 据 ExtractInterruptInfo 上抛 errInterrupted、Handle 释放 goroutine
// - resume 流 → GetResumeContext 取人工决定,落黑板(批准放行 / 拒绝置 rejected 中止下游)。
//
// 上游已中止(rejected/fatalErr)则直接跳过,不发待审、不中断。
func (o *Orchestrator) approvalInterruptLambda(t *contract.Task, n dsl.Node, tr *execTracer) func(context.Context, flowSignal) (flowSignal, error) {
return func(ctx context.Context, _ flowSignal) (flowSignal, error) {
// resume 流:应用人工决定。
if isResume, hasData, dec := compose.GetResumeContext[*contract.ApprovalDecision](ctx); isResume {
approved := hasData && dec != nil && dec.Approved
perr := compose.ProcessState(ctx, func(_ context.Context, b *board) error {
o.applyApprovalDecision(t.ID, n, b, dec, approved, tr)
return nil
})
return flowSignal{}, perr
}
// 首次执行:读待审摘要;上游已中止则跳过(不发待审、不中断)。
var title, summary string
skip := false
_ = compose.ProcessState(ctx, func(_ context.Context, b *board) error {
if b.rejected || b.fatalErr != nil {
skip = true
return nil
}
title, summary = approvalSummary(n, b)
return nil
})
if skip {
return flowSignal{}, nil
}
// 发待审 + 置 waiting,然后中断(compose 落 checkpoint、释放 goroutine)。
tr.emit("approval:"+n.ID, "approval", "await", title, summary, 0)
o.setStatus(t.ID, contract.TaskWaiting, title)
return flowSignal{}, compose.Interrupt(ctx, title)
}
}
@@ -81,6 +81,40 @@ func TestComposeApprovalRejectStopsDownstream(t *testing.T) {
}
}
// TestComposeApprovalInterruptCheckpoints 钉死中断式 HITL(接了 checkpoint 后端):
// 审批节点应 compose.Interrupt —— 返回 errInterrupted、置 waiting、checkpoint 落盘、下游 agent 不执行。
// 这是「释放 goroutine + 抗重启」的中断半边;恢复半边(resume)由增量3 覆盖。
func TestComposeApprovalInterruptCheckpoints(t *testing.T) {
graph := `{"version":"1","nodes":[
{"id":"in","kind":"input","config":{"text":"hi"}},
{"id":"ap","kind":"approval","config":{"title":"上线审批"}},
{"id":"a","kind":"agent","config":{"system":"机密操作"}}
],"edges":[
{"source":"in","target":"ap"},{"source":"ap","target":"a"}
]}`
kv := newMemKV()
st := &fakeStatus{}
fs := &fakeSink{}
o := &Orchestrator{pool: echoLLM(), breaker: harness.NewCircuitBreaker(), sink: fs, status: st}
o.SetCheckpoints(kv) // 接 checkpoint 后端 → 审批走中断模型
task := &contract.Task{ID: "t_intr", Graph: []byte(graph)}
_, _, err := o.runComposeGraph(context.Background(), task, &execTracer{})
if !errors.Is(err, errInterrupted) {
t.Fatalf("审批应中断并返回 errInterruptedgot %v", err)
}
if st.last() != contract.TaskWaiting {
t.Fatalf("中断应置 waitinggot %q", st.last())
}
if _, ok, _ := kv.Get(context.Background(), task.ID); !ok {
t.Fatalf("compose 应把图状态落进 checkpointkey=task_id),但 KV 未命中")
}
if strings.Contains(fs.text(), "ANS:") {
t.Fatalf("审批中断后下游 agent 不应执行,但 sink 含 agent 产出: %q", fs.text())
}
}
// TestComposeReturnsRefs 钉死 compose 路径回传检索来源——曾被硬写成 nil,导致忠实度评测静默失效。
// 与 runGraph 同图对照:两路径都应回流含检索片段的 refs(喂 grounded judge)。
func TestComposeReturnsRefs(t *testing.T) {
+38 -15
View File
@@ -425,12 +425,7 @@ func (o *Orchestrator) branchNode(n dsl.Node, b *board, outs []dsl.Edge, byID ma
// + 状态 waiting),阻塞等人工批准/拒绝(带超时,安全默认拒绝)。
// 批准 → 状态回 running 并放行下游;拒绝/超时 → 置 b.rejected 中止全图。返回应激活的下游(拒绝=空)。
func (o *Orchestrator) approvalNode(ctx context.Context, taskID string, n dsl.Node, b *board, tr *execTracer, outs []dsl.Edge) []string {
title := firstNonEmpty(cstr(n.Config, "title"), labelOf(n, "人工审批"))
prompt := firstNonEmpty(cstr(n.Config, "prompt"), "请审批是否继续执行后续步骤")
summary := prompt
if b.answer != "" { // 带上当前产出预览,便于审批人判断
summary = prompt + "\n—— 当前产出预览 ——\n" + truncate(b.answer, 400)
}
title, summary := approvalSummary(n, b)
// 未接审批通道(单测/降级)→ 自动放行,避免无人应答卡死。
if o.approval == nil {
@@ -443,23 +438,51 @@ func (o *Orchestrator) approvalNode(ctx context.Context, taskID string, n dsl.No
o.setStatus(taskID, contract.TaskWaiting, title)
dec, err := o.approval.WaitApproval(ctx, taskID, approvalTimeout)
switch {
case err != nil: // 超时 / ctx 取消 → 安全默认拒绝
if err != nil { // 超时 / ctx 取消 → 安全默认拒绝
b.rejected = true
b.answer = "❌ 审批超时未决,已自动拒绝:" + title
tr.emit("approval:"+n.ID, "approval", "error", title, "审批超时,自动拒绝", 0)
return nil
case !dec.Approved:
}
// 批准/拒绝的落盘与终态事件与中断式审批共用,杜绝两路文案/语义漂移。
o.applyApprovalDecision(taskID, n, b, dec, dec.Approved, tr)
if b.rejected {
return nil
}
return targetsOf(outs)
}
// approvalSummary 据审批节点配置 + 当前成稿,算出待审标题与摘要(带产出预览,便于审批人判断)。
func approvalSummary(n dsl.Node, b *board) (title, summary string) {
title = firstNonEmpty(cstr(n.Config, "title"), labelOf(n, "人工审批"))
prompt := firstNonEmpty(cstr(n.Config, "prompt"), "请审批是否继续执行后续步骤")
summary = prompt
if b.answer != "" {
summary = prompt + "\n—— 当前产出预览 ——\n" + truncate(b.answer, 400)
}
return title, summary
}
// applyApprovalDecision 把一次人工决定落到黑板并发终态事件:拒绝 → 置 rejected + 拒绝语;
// 批准 → 状态回 running 放行。阻塞式(approvalNode)与中断式(approvalInterruptLambda)审批共用。
func (o *Orchestrator) applyApprovalDecision(taskID string, n dsl.Node, b *board, dec *contract.ApprovalDecision, approved bool, tr *execTracer) {
title := firstNonEmpty(cstr(n.Config, "title"), labelOf(n, "人工审批"))
if !approved {
note := "审批人拒绝"
if dec != nil {
note = firstNonEmpty(dec.Note, note)
}
b.rejected = true
note := firstNonEmpty(dec.Note, "审批人拒绝")
b.answer = "❌ 已被拒绝:" + note
tr.emit("approval:"+n.ID, "approval", "end", title, "拒绝:"+note, 0)
return nil
default: // 批准 → 恢复执行,放行下游
o.setStatus(taskID, contract.TaskRunning, "审批通过,继续执行")
tr.emit("approval:"+n.ID, "approval", "end", title, "批准:"+firstNonEmpty(dec.Note, "放行"), 0)
return targetsOf(outs)
return
}
note := "放行"
if dec != nil {
note = firstNonEmpty(dec.Note, note)
}
o.setStatus(taskID, contract.TaskRunning, "审批通过,继续执行")
tr.emit("approval:"+n.ID, "approval", "end", title, "批准:"+note, 0)
}
// targetsOf 取一组边的目标节点 ID(保持顺序)。
@@ -65,6 +65,10 @@ var errBudget = errors.New("token 预算超限,已中止")
// Handle 据此判 rejected 并优雅收尾(不计熔断失败)。
var errRejected = errors.New("approval rejected")
// errInterrupted 是 HITL 审批中断(compose checkpoint)时图执行返回的哨兵错误:任务停在
// waiting、checkpoint 已落、goroutine 即可释放;非故障非终态,等人工决定到达后 resume 续跑。
var errInterrupted = errors.New("approval interrupted (checkpointed)")
// LLM 是编排所需的语言模型能力(生产由 *llm.Pool 实现)。抽成接口便于测试注入假模型。
type LLM interface {
Ready() bool
@@ -100,6 +104,7 @@ type Orchestrator struct {
evalSink EvalSink // 评测结果回写落库(可为 nil → 仅打日志)
guard *harness.Classifier // 输入护栏 Tier2:对网关标记的灰区任务做 LLM 裁决(可为 nil → 不做)
usageSink UsageSink // token 用量回写(可为 nil → 不回写)
checkpoints CheckpointKV // HITL 持久化中断的 checkpoint 后端(可为 nil → 审批走阻塞模型)
turnMu sync.Mutex // 保护 turns(攒批计数,多任务 goroutine 共享)
turns map[string]int // sessionID → 累计轮次,用于每 N 轮触发 consolidate
@@ -118,6 +123,10 @@ func (o *Orchestrator) SetGuardian(c *harness.Classifier) { o.guard = c }
// SetUsageSink 注入 token 用量回写出口(可选;不注入则不上报用量)。
func (o *Orchestrator) SetUsageSink(s UsageSink) { o.usageSink = s }
// SetCheckpoints 注入 HITL 持久化中断的 checkpoint 后端(可选)。注入后审批节点改走
// compose.Interrupt 中断模型(落 checkpoint、释放 goroutine、抗重启);不注入则维持阻塞模型。
func (o *Orchestrator) SetCheckpoints(kv CheckpointKV) { o.checkpoints = kv }
// taskBudget 取本任务的 token 预算上限:优先 Meta(网关按用户/套餐下发),否则 env TASK_TOKEN_BUDGET(默认 20 万)。
func (o *Orchestrator) taskBudget(t *contract.Task) int {
switch n := t.Meta[contract.MetaTokenBudget].(type) {
@@ -254,6 +263,14 @@ func (o *Orchestrator) Handle(ctx context.Context, t *contract.Task) error {
// 按 DSL 图执行:compose.GraphEINO_COMPOSE=1)或自研 graph.go(默认);agent 节点流式回流 token。
answer, refs, err := o.executeGraph(tctx, t, tr)
if errors.Is(err, errInterrupted) {
// HITL 持久化中断:checkpoint 已落、任务停在 waiting(状态在审批节点内已置)。
// 释放 goroutine 但不收尾——不 CompleteStream、不评测、不判 done、不计熔断失败;
// SSE 流保持打开,UI 继续显示待审。人工决定到达后由 resume 续跑(增量3)。
slog.InfoContext(ctx, "task interrupted for approval (checkpointed)", "task_id", t.ID)
o.breaker.Report(true) // 中断是正常暂停,非后端故障
return nil
}
if errors.Is(err, errRejected) {
// HITL 拒绝:合法终态,非故障。收尾流 + 置 rejected,不计熔断、不重投。
slog.InfoContext(ctx, "task rejected by approval", "task_id", t.ID)