feat(hitl): 人工审批中断(Eino Phase D)—— 审批节点暂停→批准续跑/拒绝中止
专用「审批」节点方案,全栈打通。 后端: - contract:TaskWaiting/TaskRejected 状态 + ApprovalSubject/ApprovalDecision。 - bus:PublishApproval + WaitApproval(订阅决定主题,带超时);消费者 AckWait 30s→15min(阻塞等人审期间消息未 ack,否则重投成重复任务)。 - dispatcher:ApprovalWaiter 接口 + approvalNode——执行到审批节点发 await 事件 + 置 waiting,阻塞等决定。批准→回 running 放行下游;拒绝/超时→errRejected 哨兵→ 剪下游→rejected,优雅收尾不计熔断。超时安全默认拒绝。 taskExecTimeout 3→10min(审批5 < 执行10 < AckWait15)。 - 网关:POST /tasks/:id/approve(仅 waiting 态受理,幂等)。 桌面端: - Studio 新增「人工审批」节点(nodeCatalog,可填标题/说明)。 - run.ts pendingApproval() 从 exec 流派生待审中断 + waiting 节点状态。 - BottomDrawer ApprovalBar:琥珀审批条(摘要 + 批准/拒绝 + 备注,调 api.approveTask)。 - ExecTrace waiting 状态灯。 验证:后端 curl 实测 waiting→批准→running→done;waiting→拒绝→rejected(下游未跑)。 前端 tsc + vitest 36 过(pendingApproval 4 例 + waiting 状态)。全模块 build+vet+test 全绿。 EINO_ADOPTION Phase D 标记 HITL 完成。 未覆盖:compose 路径(EINO_COMPOSE,默认关)的 approval 节点;桌面端审批条未在 GUI 实点。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -314,6 +314,48 @@ func (b *Bus) SubscribeTaskStatus(onEvent func(*contract.TaskStatusEvent)) (unsu
|
||||
return sub.Unsubscribe, nil
|
||||
}
|
||||
|
||||
// ---- 人工审批(HITL,core NATS pub-sub)----
|
||||
|
||||
// PublishApproval 广播一次人工审批决定(网关在收到 UI 的批准/拒绝后调用)。
|
||||
func (b *Bus) PublishApproval(dec *contract.ApprovalDecision) error {
|
||||
data, err := json.Marshal(dec)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return b.nc.Publish(contract.ApprovalSubject(dec.TaskID), data)
|
||||
}
|
||||
|
||||
// WaitApproval 阻塞等待某任务的人工审批决定,直到收到、ctx 取消或超时。
|
||||
// dispatcher 在审批节点调用:先订阅再等待(订阅早于决定到达,避免错过)。
|
||||
// 超时返回 (nil, error) —— 调用方据安全默认(拒绝)处理。
|
||||
func (b *Bus) WaitApproval(ctx context.Context, taskID string, timeout time.Duration) (*contract.ApprovalDecision, error) {
|
||||
ch := make(chan *contract.ApprovalDecision, 1)
|
||||
sub, err := b.nc.Subscribe(contract.ApprovalSubject(taskID), func(m *nats.Msg) {
|
||||
var dec contract.ApprovalDecision
|
||||
if json.Unmarshal(m.Data, &dec) == nil {
|
||||
select {
|
||||
case ch <- &dec:
|
||||
default: // 已收到一条,丢弃后续重复决定
|
||||
}
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("subscribe approval: %w", err)
|
||||
}
|
||||
defer func() { _ = sub.Unsubscribe() }()
|
||||
|
||||
timer := time.NewTimer(timeout)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case dec := <-ch:
|
||||
return dec, nil
|
||||
case <-timer.C:
|
||||
return nil, fmt.Errorf("approval timeout after %s", timeout)
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 配置控制面(core NATS request-reply + broadcast)----
|
||||
|
||||
// RequestConfig 向控制面(Gateway)请求某 kind 当前激活配置(chat/embedding)。
|
||||
@@ -406,6 +448,9 @@ func (b *Bus) ConsumeTasks(ctx context.Context, h TaskHandler) (stop func(), err
|
||||
Durable: contract.ConsumerDurable,
|
||||
AckPolicy: jetstream.AckExplicitPolicy,
|
||||
FilterSubject: contract.SubjectTasksAll,
|
||||
// HITL:审批节点会让 Handle 阻塞等人工决定(最长约 5 分钟),
|
||||
// AckWait 必须覆盖「审批等待 + 图执行」总时长,否则消息在途未 ack 会被重投成重复任务。
|
||||
AckWait: 15 * time.Minute,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create consumer: %w", err)
|
||||
|
||||
@@ -33,26 +33,46 @@ const (
|
||||
// core NATS pub-sub(状态是幂等覆盖,丢一条由下一条纠正,无需持久化)。
|
||||
// 注意:必须在 sundynix.tasks.> 之外,否则会被任务流捕获成"幽灵任务"自我放大。
|
||||
SubjectTaskStatus = "sundynix.status.task"
|
||||
|
||||
// 人工审批(HITL)决定回传前缀:实际 sundynix.approval.<task_id>。
|
||||
// core NATS pub-sub:UI 点批准/拒绝 → 网关发到此 → dispatcher 解除审批节点的阻塞。
|
||||
SubjectApproval = "sundynix.approval"
|
||||
)
|
||||
|
||||
// 任务生命周期状态机:submitted(网关建任务)→ running(dispatcher 开跑)
|
||||
// → done / failed / timeout(dispatcher 收尾)。
|
||||
// HITL:执行到审批节点 → waiting(等人工决定)→ 批准回 running / 拒绝→rejected。
|
||||
const (
|
||||
TaskSubmitted = "submitted"
|
||||
TaskRunning = "running"
|
||||
TaskDone = "done"
|
||||
TaskFailed = "failed"
|
||||
TaskTimeout = "timeout"
|
||||
TaskWaiting = "waiting" // 等待人工审批
|
||||
TaskRejected = "rejected" // 人工拒绝(或审批超时,安全默认拒绝)
|
||||
)
|
||||
|
||||
// TaskStatusEvent 是一次任务状态流转事件(经 SubjectTaskStatus 回流给网关)。
|
||||
type TaskStatusEvent struct {
|
||||
TaskID string `json:"task_id"`
|
||||
Status string `json:"status"` // running / done / failed / timeout
|
||||
Detail string `json:"detail,omitempty"` // 失败原因等
|
||||
Status string `json:"status"` // running / done / failed / timeout / waiting / rejected
|
||||
Detail string `json:"detail,omitempty"` // 失败原因 / 审批摘要等
|
||||
TS int64 `json:"ts"` // unix 毫秒
|
||||
}
|
||||
|
||||
// ApprovalSubject 返回某任务的人工审批决定回传主题。
|
||||
func ApprovalSubject(id string) string { return SubjectApproval + "." + id }
|
||||
|
||||
// ApprovalDecision 是一次人工审批的结果(UI → 网关 → dispatcher)。
|
||||
type ApprovalDecision struct {
|
||||
TaskID string `json:"task_id"`
|
||||
Node string `json:"node,omitempty"` // 审批节点 id(可空:按 task 维度兜底匹配)
|
||||
Approved bool `json:"approved"` // true=批准放行,false=拒绝中止
|
||||
Note string `json:"note,omitempty"` // 审批人备注
|
||||
By string `json:"by,omitempty"` // 审批人(用户 id)
|
||||
TS int64 `json:"ts"` // unix 毫秒
|
||||
}
|
||||
|
||||
const (
|
||||
// MetaUserID 是 Task.Meta 中承载已登录用户标识的键(用于偏好记忆召回)。
|
||||
MetaUserID = "user_id"
|
||||
|
||||
Reference in New Issue
Block a user