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:
@@ -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.Graph(EINO_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)
|
||||
|
||||
Reference in New Issue
Block a user