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:
@@ -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.Interrupt:compose 把整图状态(含 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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user