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
@@ -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
@@ -89,17 +93,18 @@ const approvalTimeout = 5 * time.Minute
// Orchestrator 把每个 DSL 任务动态编译为 Eino 图并执行(记忆召回 → 工具节点 → 注入 → 流式)。
type Orchestrator struct {
pool LLM
breaker *harness.CircuitBreaker
eval *harness.Evaluator
sink TokenSink
tools ToolCaller
exec ExecSink
status StatusSink // 任务生命周期状态回写(可为 nil)
approval ApprovalWaiter // HITL 审批等待(可为 nil → 审批节点自动放行)
evalSink EvalSink // 评测结果回写落库(可为 nil → 仅打日志)
guard *harness.Classifier // 输入护栏 Tier2:对网关标记的灰区任务做 LLM 裁决(可为 nil → 不做)
usageSink UsageSink // token 用量回写(可为 nil → 不回写)
pool LLM
breaker *harness.CircuitBreaker
eval *harness.Evaluator
sink TokenSink
tools ToolCaller
exec ExecSink
status StatusSink // 任务生命周期状态回写(可为 nil)
approval ApprovalWaiter // HITL 审批等待(可为 nil → 审批节点自动放行)
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)