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)
}
}