Why: A parallel group only checked the first step, free text cancelled confirmation and permission holds, and a failed reply deleted the pending request before the decision ran. Evidence: GOWORK=off go vet ./...; go test -count=1 ./squad/ ./cli/; go test -race -count=1 ./squad/. New tests cover the second parallel member, free-text holds, restored pending, AtomicWrite, and listenGuard. Impact: Any member that needs approval or bash permission keeps the whole group suspended. Free text on confirmation, permission, and quality review is stored as steering and the same request stays pending. A dispatch error restores pending.json. Refs: feature/agent-stack-r0-r1 Co-authored-by: Cursor <cursoragent@cursor.com>
1234 lines
40 KiB
Go
1234 lines
40 KiB
Go
package squad
|
||
|
||
import (
|
||
"context"
|
||
"crypto/sha256"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"os"
|
||
"path/filepath"
|
||
"sort"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"error-code-system/errcode"
|
||
)
|
||
|
||
// Options 引擎组装选项(组合根装配;库内零产品依赖)。
|
||
type Options struct {
|
||
DataDir string // 数据根:runs/、roles.d/、blueprints.d/ 所在目录(必填)
|
||
Registry *RoleRegistry
|
||
Blueprints *BlueprintRegistry
|
||
Planner Planner
|
||
Backends *BackendResolver
|
||
Evaluator StepEvaluator
|
||
Checker Checker
|
||
Sink ProgressSink
|
||
// DefaultBackend 角色未声明 Backend 时的默认智能体名(默认 stub)。
|
||
DefaultBackend string
|
||
// StepTimeout 单步执行超时(默认 10 分钟)。
|
||
StepTimeout time.Duration
|
||
// HitlPolicy 全局 HITL 策略(角色/Goal 可覆盖)。
|
||
HitlPolicy HitlPolicy
|
||
// RepairBudget 质检失败自动修复次数(默认 1)。
|
||
RepairBudget int
|
||
// IntentGate 可选意图闸门:Run 入口一次确定性裁决(nil = 不启用,
|
||
// 行为与原实现一致)。见 intent.go;实现方为 squadintent 适配器。
|
||
IntentGate IntentGate
|
||
// IntentCatalog 可选能力目录(squadintent 渲染):注入 PlanInput 与 leader 提示词,
|
||
// 并用于 intent 步骤的确定性校验。nil = 未启用目录驱动编排。
|
||
IntentCatalog *IntentCatalog
|
||
// IntentRunner 系统功能步执行器(squadintent 提供)。目录启用而 Runner 缺席时,
|
||
// intent 步骤 fail-closed。
|
||
IntentRunner IntentRunner
|
||
// Router 可选分诊。direct = 单步 assistant,不进多角色;plan = leader 委派。
|
||
// nil = 保持调用方传入的 RunOptions.Mode。
|
||
Router Router
|
||
}
|
||
|
||
// engineDeps 驱动器依赖(由引擎从 Options 派生)。
|
||
type engineDeps struct {
|
||
Registry *RoleRegistry
|
||
Backends *BackendResolver
|
||
Evaluator StepEvaluator
|
||
Checker Checker
|
||
Sink ProgressSink
|
||
Workspaces *WorkspaceEngine
|
||
StepTimeout time.Duration
|
||
HitlPolicy HitlPolicy
|
||
IntentRunner IntentRunner
|
||
}
|
||
|
||
// RunOptions 单次运行的可选项。
|
||
type RunOptions struct {
|
||
// TaskKey 显式指定蓝图模板键;空则按 Goal.Text 匹配或自主规划。
|
||
TaskKey string `json:"task_key,omitempty"`
|
||
// Backend 运行级默认后端(覆盖引擎 DefaultBackend)。
|
||
Backend string `json:"backend,omitempty"`
|
||
// Mode 编排模式:standard(默认,一次性计划顺序执行)| leader
|
||
// (leader-delegation:leader 角色逐步委派成员,成员产出后唤醒 leader 再评估)。
|
||
Mode string `json:"mode,omitempty"`
|
||
// LeaderRole leader 模式下的 leader 角色 key(必须已注册或在计划编制内)。
|
||
LeaderRole string `json:"leader_role,omitempty"`
|
||
// MaxLeaderTurns leader 模式每运行的 leader 轮次上限(默认 8,防失控)。
|
||
MaxLeaderTurns int `json:"max_leader_turns,omitempty"`
|
||
}
|
||
|
||
// SquadEngine 小队引擎门面:Run / Respond / Resume / Inspect / ListRuns。
|
||
// 每个运行同一时刻只允许一个控制操作(Run/Respond 互斥)。
|
||
type SquadEngine struct {
|
||
opts Options
|
||
deps engineDeps
|
||
ws *WorkspaceEngine
|
||
|
||
mu sync.Mutex
|
||
runLocks map[string]*sync.Mutex
|
||
}
|
||
|
||
// NewEngine 组装引擎并应用默认值。
|
||
func NewEngine(opts Options) (*SquadEngine, error) {
|
||
if opts.DataDir == "" {
|
||
return nil, fmt.Errorf("DataDir is required")
|
||
}
|
||
if opts.Registry == nil {
|
||
opts.Registry = NewRoleRegistry()
|
||
}
|
||
if opts.Blueprints == nil {
|
||
opts.Blueprints = NewBlueprintRegistry()
|
||
}
|
||
if opts.Planner == nil {
|
||
opts.Planner = NewRulePlanner()
|
||
}
|
||
if opts.Backends == nil {
|
||
opts.Backends = NewBackendResolver()
|
||
}
|
||
if opts.Evaluator == nil {
|
||
opts.Evaluator = NoOpEvaluator{}
|
||
}
|
||
if opts.Checker == nil {
|
||
opts.Checker = MinimalChecker{}
|
||
}
|
||
if opts.Sink == nil {
|
||
opts.Sink = NopSink{}
|
||
}
|
||
if opts.DefaultBackend == "" {
|
||
opts.DefaultBackend = "stub"
|
||
}
|
||
if opts.StepTimeout == 0 {
|
||
opts.StepTimeout = 10 * time.Minute
|
||
}
|
||
if opts.RepairBudget == 0 {
|
||
opts.RepairBudget = 1
|
||
}
|
||
if opts.HitlPolicy.Mode == "" {
|
||
opts.HitlPolicy.Mode = HitlInteractive
|
||
}
|
||
ws := &WorkspaceEngine{DataDir: opts.DataDir}
|
||
deps := engineDeps{
|
||
Registry: opts.Registry, Backends: opts.Backends, Evaluator: opts.Evaluator,
|
||
Checker: opts.Checker, Sink: opts.Sink, Workspaces: ws,
|
||
StepTimeout: opts.StepTimeout, HitlPolicy: opts.HitlPolicy,
|
||
IntentRunner: opts.IntentRunner,
|
||
}
|
||
return &SquadEngine{opts: opts, deps: deps, ws: ws, runLocks: map[string]*sync.Mutex{}}, nil
|
||
}
|
||
|
||
// Registry 暴露角色注册表(只读使用)。
|
||
func (e *SquadEngine) Registry() *RoleRegistry { return e.opts.Registry }
|
||
|
||
// Blueprints 暴露蓝图注册表。
|
||
func (e *SquadEngine) Blueprints() *BlueprintRegistry { return e.opts.Blueprints }
|
||
|
||
func (e *SquadEngine) runLock(runID string) *sync.Mutex {
|
||
e.mu.Lock()
|
||
defer e.mu.Unlock()
|
||
l, ok := e.runLocks[runID]
|
||
if !ok {
|
||
l = &sync.Mutex{}
|
||
e.runLocks[runID] = l
|
||
}
|
||
return l
|
||
}
|
||
|
||
// ErrRunBusy 表示该 run 的文件锁正被占用,且在 ctx 截止前没有拿到。
|
||
// 非 unix 平台没有 flock,只保留进程内互斥;跨进程互斥需要 unix。
|
||
var ErrRunBusy = errors.New("run is busy")
|
||
|
||
// acquire 进入 run 临界区:先校验 id,再拿进程内锁,然后按 ctx 轮询文件锁。
|
||
func (e *SquadEngine) acquire(ctx context.Context, runID string) (func(), error) {
|
||
if !ValidID(runID) {
|
||
return nil, invalidID(runID)
|
||
}
|
||
if ctx == nil {
|
||
ctx = context.Background()
|
||
}
|
||
mu := e.runLock(runID)
|
||
mu.Lock()
|
||
release, err := lockRunFile(ctx, filepath.Join(e.opts.DataDir, "runs", runID))
|
||
if err != nil {
|
||
mu.Unlock()
|
||
return nil, err
|
||
}
|
||
return func() {
|
||
release()
|
||
mu.Unlock()
|
||
}, nil
|
||
}
|
||
|
||
// Run 发起一次运行:规划(含澄清/确认挂起)→ 执行 → 收口。
|
||
func (e *SquadEngine) Run(ctx context.Context, goal Goal, runOpts RunOptions) (*RunResult, error) {
|
||
if strings.TrimSpace(goal.Text) == "" {
|
||
return nil, fmt.Errorf("goal text is required")
|
||
}
|
||
runID := fmt.Sprintf("run_%s", time.Now().UTC().Format("20060102T150405.000000000"))
|
||
release, err := e.acquire(ctx, runID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer release()
|
||
|
||
meta := RunMetadata{
|
||
ID: runID, Goal: goal, Status: string(RunPlanning),
|
||
Backend: firstNonEmpty(runOpts.Backend, e.opts.DefaultBackend),
|
||
Planner: e.opts.Planner.Name(),
|
||
CreatedAt: time.Now(), UpdatedAt: time.Now(),
|
||
Metadata: map[string]string{
|
||
"task_key": runOpts.TaskKey,
|
||
"mode": runOpts.Mode,
|
||
},
|
||
}
|
||
if runOpts.Mode == "leader" {
|
||
meta.Metadata["leader_role"] = runOpts.LeaderRole
|
||
if runOpts.MaxLeaderTurns > 0 {
|
||
meta.Metadata["max_leader_turns"] = fmt.Sprintf("%d", runOpts.MaxLeaderTurns)
|
||
}
|
||
}
|
||
e.deps.Sink.Emit(Event{Type: EvRunStarted, RunID: runID, Text: goal.Text, At: time.Now()})
|
||
|
||
// 意图闸门(可选,入口一次裁决;显式 TaskKey 优先,不重复过闸)。
|
||
// nil 决策 = 不干预走原路径;闸门系统错误与守卫拒绝同样 fail-closed 成失败运行。
|
||
if e.opts.IntentGate != nil && runOpts.TaskKey == "" {
|
||
dec, derr := e.opts.IntentGate.Decide(ctx, goal, runOpts)
|
||
if derr != nil {
|
||
e.deps.Sink.Emit(Event{Type: EvRunFailed, RunID: runID, Text: derr.Error(), At: time.Now()})
|
||
return e.failNew(&meta, derr)
|
||
}
|
||
if dec != nil {
|
||
if rej := dec.Reject; rej != nil {
|
||
if dec.IntentID != "" {
|
||
meta.Metadata["intent_id"] = dec.IntentID
|
||
}
|
||
err := errcode.New(errcode.SquadIntentRefused, rej.Reason, "guidance", rej.Guidance)
|
||
e.deps.Sink.Emit(Event{Type: EvRunFailed, RunID: runID, Text: err.Error(), At: time.Now()})
|
||
return e.failNew(&meta, err)
|
||
}
|
||
if dec.BlueprintKey != "" {
|
||
if _, ok := e.opts.Blueprints.Get(dec.BlueprintKey); !ok {
|
||
err := errcode.New(errcode.SquadBlueprintNotFound,
|
||
fmt.Sprintf("意图 %s 定向的蓝图 %s 不存在", dec.IntentID, dec.BlueprintKey))
|
||
e.deps.Sink.Emit(Event{Type: EvRunFailed, RunID: runID, Text: err.Error(), At: time.Now()})
|
||
return e.failNew(&meta, err)
|
||
}
|
||
runOpts.TaskKey = dec.BlueprintKey
|
||
meta.Metadata["intent_id"] = dec.IntentID
|
||
meta.Metadata["intent_blueprint"] = dec.BlueprintKey
|
||
}
|
||
}
|
||
}
|
||
|
||
st := &runState{Meta: meta, Approved: map[string]bool{}, RepairBudgetRemaining: e.opts.RepairBudget}
|
||
return e.planAndExecute(ctx, st, runOpts)
|
||
}
|
||
|
||
// planAndExecute 规划 → 挂起或执行;st 可为已有运行状态(澄清续跑复用)。
|
||
func (e *SquadEngine) planAndExecute(ctx context.Context, st *runState, runOpts RunOptions) (*RunResult, error) {
|
||
drv := &flowDriver{opts: e.deps}
|
||
meta := &st.Meta
|
||
|
||
if e.opts.Router != nil && runOpts.Mode == "" && meta.Metadata["route"] == "" {
|
||
dec, rerr := e.opts.Router.Route(ctx, meta.Goal)
|
||
if rerr != nil {
|
||
return e.failNew(meta, rerr)
|
||
}
|
||
applyRoute(meta, dec, runOpts.Mode)
|
||
}
|
||
|
||
var planOut *PlanOutput
|
||
var err error
|
||
if meta.Metadata["route_direct"] == "1" {
|
||
plan, perr := directAssistantPlan(e.opts.Registry, meta.Goal)
|
||
if perr != nil {
|
||
return e.failNew(meta, perr)
|
||
}
|
||
planOut = &PlanOutput{Plan: plan}
|
||
} else {
|
||
bpMatch, _ := e.opts.Blueprints.Match(meta.Goal.Text, runOpts.TaskKey)
|
||
planOut, err = e.opts.Planner.Plan(ctx, PlanInput{
|
||
Goal: meta.Goal, Blueprint: bpPtr(bpMatch), Registry: e.opts.Registry, RunID: meta.ID,
|
||
IntentCatalog: e.opts.IntentCatalog,
|
||
})
|
||
if err != nil {
|
||
return e.failNew(meta, err)
|
||
}
|
||
}
|
||
// 目录驱动编排的确定性校验:intent 步骤命中目录、必填槽位齐、枚举合法(fail-closed)。
|
||
if err := validateIntentSteps(planOut.Plan, e.opts.IntentCatalog); err != nil {
|
||
e.deps.Sink.Emit(Event{Type: EvRunFailed, RunID: meta.ID, Text: err.Error(), At: time.Now()})
|
||
return e.failNew(meta, err)
|
||
}
|
||
// 规划歧义:挂起澄清(auto/disabled 模式直接失败,不猜计划)。
|
||
if planOut.Clarification != nil {
|
||
if drv.policy(st).mode() != HitlInteractive {
|
||
return e.failNew(meta, fmt.Errorf("planner requested clarification but hitl mode is %s", st.policy().mode()))
|
||
}
|
||
var qs []string
|
||
for _, q := range planOut.Clarification.Questions {
|
||
qs = append(qs, "- "+q.Text)
|
||
}
|
||
opts := []HumanOption{}
|
||
if len(planOut.Clarification.Questions) == 1 && len(planOut.Clarification.Questions[0].Options) > 0 {
|
||
opts = planOut.Clarification.Questions[0].Options
|
||
}
|
||
req := &HumanRequest{
|
||
ID: newRequestID(), RunID: meta.ID, Kind: HitlClarification, Severity: SeverityActionRequired,
|
||
Question: "规划器无法确定如何执行该目标,请补充信息:",
|
||
Options: opts,
|
||
Context: fmt.Sprintf("原因:%s\n问题:\n%s", planOut.Clarification.Reason, strings.Join(qs, "\n")),
|
||
CreatedAt: time.Now(), Status: "pending",
|
||
}
|
||
gate := &hitlGate{Request: *req, PrevStatus: RunPlanning}
|
||
// 澄清挂起也需要工作区承载 pending.json。
|
||
if err := e.materializeWorkspace(meta, st, &SquadPlan{Workflow: WorkflowPlan{WorkflowID: NewPlanID(), Status: "pending", Steps: nil}}); err != nil {
|
||
return nil, err
|
||
}
|
||
if err := st.saveGate(gate); err != nil {
|
||
return nil, err
|
||
}
|
||
st.Meta.Status = string(RunAwaitingClarification)
|
||
_ = st.save()
|
||
e.deps.Sink.Emit(Event{Type: EvHumanInputRequired, RunID: meta.ID, Text: req.Question, Data: marshalJSON(req), At: time.Now()})
|
||
return &RunResult{RunID: meta.ID, Status: RunAwaitingClarification, Pending: req, WorkDir: st.Meta.WorkDir}, nil
|
||
}
|
||
|
||
// 计划确认:interactive 模式先挂起。leader 模式例外——规划步骤会被
|
||
// leader 委派循环取代,派生编制本身不构成执行承诺,无需确认。
|
||
if planOut.NeedsConfirmation && drv.policy(st).mode() == HitlInteractive && meta.Metadata["mode"] != "leader" {
|
||
if err := e.materializeWorkspace(meta, st, planOut.Plan); err != nil {
|
||
return nil, err
|
||
}
|
||
var lines []string
|
||
for _, s := range planOut.Plan.Workflow.Steps {
|
||
lines = append(lines, fmt.Sprintf("- %s [%s] %s(产出 %v)", s.ID, s.Role, s.Title, s.Targets))
|
||
}
|
||
var roles []string
|
||
for _, a := range planOut.Plan.Squad.Agents {
|
||
roles = append(roles, fmt.Sprintf("- %s(%s,backend=%s model=%s)", a.Role, a.Name, a.Backend, a.Model))
|
||
}
|
||
req := &HumanRequest{
|
||
ID: newRequestID(), RunID: meta.ID, Kind: HitlConfirmation, Severity: SeverityActionRequired,
|
||
Question: "计划已生成,需要人工确认后执行:",
|
||
Options: []HumanOption{
|
||
{Value: "approve", Label: "批准并执行"},
|
||
{Value: "abort", Label: "放弃执行"},
|
||
},
|
||
Context: fmt.Sprintf("确认原因:%s\n编制:\n%s\n步骤:\n%s",
|
||
planOut.ConfirmReason, strings.Join(roles, "\n"), strings.Join(lines, "\n")),
|
||
CreatedAt: time.Now(), Status: "pending",
|
||
Reason: "plan",
|
||
}
|
||
gate := &hitlGate{Request: *req, Plan: planOut.Plan, PrevStatus: RunPlanning}
|
||
if err := st.saveGate(gate); err != nil {
|
||
return nil, err
|
||
}
|
||
st.Meta.Status = string(RunAwaitingConfirmation)
|
||
_ = st.save()
|
||
e.deps.Sink.Emit(Event{Type: EvHumanInputRequired, RunID: meta.ID, Text: req.Question, Data: marshalJSON(req), At: time.Now()})
|
||
return &RunResult{RunID: meta.ID, Status: RunAwaitingConfirmation, Pending: req, Plan: &planOut.Plan.Workflow, WorkDir: st.Meta.WorkDir}, nil
|
||
}
|
||
|
||
if err := e.materializeWorkspace(meta, st, planOut.Plan); err != nil {
|
||
return nil, err
|
||
}
|
||
// leader-delegation 模式:忽略规划步骤,进 leader 委派循环。
|
||
if meta.Metadata["mode"] == "leader" {
|
||
e.initLeaderState(st)
|
||
out, gate, err := e.runLeaderLoop(ctx, st)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if gate == nil {
|
||
e.finalizeEvent(out)
|
||
}
|
||
return out, nil
|
||
}
|
||
out, _, err := drv.advance(ctx, st)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
e.finalizeEvent(out)
|
||
return out, nil
|
||
}
|
||
|
||
// initLeaderState 按运行选项初始化 leader 循环状态。
|
||
func (e *SquadEngine) initLeaderState(st *runState) {
|
||
leaderRole := st.Meta.Metadata["leader_role"]
|
||
if leaderRole == "" {
|
||
if _, ok := findAgent(st.Plan.Squad.Agents, "leader"); ok {
|
||
leaderRole = "leader"
|
||
}
|
||
}
|
||
maxTurns := DefaultMaxLeaderTurns
|
||
if v := st.Meta.Metadata["max_leader_turns"]; v != "" {
|
||
if n := parseIntDefault(v, DefaultMaxLeaderTurns); n > 0 {
|
||
maxTurns = n
|
||
}
|
||
}
|
||
st.Leader = &LeaderState{LeaderRole: leaderRole, MaxTurns: maxTurns}
|
||
st.saveLeader()
|
||
}
|
||
|
||
func parseIntDefault(s string, def int) int {
|
||
n := 0
|
||
for _, c := range s {
|
||
if c < '0' || c > '9' {
|
||
return def
|
||
}
|
||
n = n*10 + int(c-'0')
|
||
}
|
||
return n
|
||
}
|
||
|
||
// isLeaderRun 该运行是否处于 leader-delegation 模式。
|
||
func (e *SquadEngine) isLeaderRun(st *runState) bool {
|
||
return st.Meta.Metadata["mode"] == "leader"
|
||
}
|
||
|
||
// resumeAfterHold 挂起解除后的统一续跑入口(按模式分发)。
|
||
func (e *SquadEngine) resumeAfterHold(ctx context.Context, st *runState) (*RunResult, error) {
|
||
if e.isLeaderRun(st) {
|
||
out, gate, err := e.runLeaderLoop(ctx, st)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if gate == nil {
|
||
e.finalizeEvent(out)
|
||
}
|
||
return out, nil
|
||
}
|
||
drv := &flowDriver{opts: e.deps}
|
||
out, _, err := drv.advance(ctx, st)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
e.finalizeEvent(out)
|
||
return out, nil
|
||
}
|
||
|
||
// materializeWorkspace 落盘工作区与子工作区(已存在则只更新计划并重建待执行子目录)。
|
||
func (e *SquadEngine) materializeWorkspace(meta *RunMetadata, st *runState, plan *SquadPlan) error {
|
||
if st.Meta.WorkDir == "" {
|
||
st.Meta = *meta
|
||
wsDir, err := e.ws.PrepareWorkspace(&st.Meta, plan)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
st.Template = plan.Template
|
||
bb, _ := loadBlackboard(wsDir)
|
||
st.BB = *bb
|
||
} else {
|
||
// 复用既有工作区:磁盘黑板为权威(澄清续跑/计划更新场景)。
|
||
if bb, err := loadBlackboard(st.Meta.WorkDir); err == nil {
|
||
st.BB = *bb
|
||
}
|
||
if st.Template == "" {
|
||
st.Template = plan.Template
|
||
}
|
||
}
|
||
st.Plan = *plan
|
||
// 编制补全:步骤引用但编制缺失的角色从注册表解析(蓝图/派生计划常见)。
|
||
if err := ensureSquadCoversSteps(&st.Plan, e.opts.Registry); err != nil {
|
||
return err
|
||
}
|
||
if err := e.ws.BuildSubWorkspaces(st.Meta.WorkDir, &st.Plan, &st.BB); err != nil {
|
||
return err
|
||
}
|
||
return st.save()
|
||
}
|
||
|
||
// ensureSquadCoversSteps 确保编制覆盖全部步骤角色。
|
||
func ensureSquadCoversSteps(plan *SquadPlan, reg *RoleRegistry) error {
|
||
for _, step := range plan.Workflow.Steps {
|
||
if _, ok := findAgent(plan.Squad.Agents, step.Role); ok {
|
||
continue
|
||
}
|
||
spec, ok := reg.Get(step.Role)
|
||
if !ok {
|
||
return fmt.Errorf("step %s references role %s not registered", step.ID, step.Role)
|
||
}
|
||
plan.Squad.Agents = append(plan.Squad.Agents, spec)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (e *SquadEngine) failNew(meta *RunMetadata, err error) (*RunResult, error) {
|
||
return &RunResult{RunID: meta.ID, Status: RunFailed, Error: err.Error()}, nil
|
||
}
|
||
|
||
// Respond 应用人工决策并续跑。按请求 ID 幂等:重复回应同一请求返回当前状态。
|
||
func (e *SquadEngine) Respond(ctx context.Context, runID string, decision RespondDecision) (*RunResult, error) {
|
||
release, err := e.acquire(ctx, runID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer release()
|
||
return e.respondLocked(ctx, runID, decision)
|
||
}
|
||
|
||
func (e *SquadEngine) respondLocked(ctx context.Context, runID string, decision RespondDecision) (*RunResult, error) {
|
||
wsDir := filepath.Join(e.opts.DataDir, "runs", runID)
|
||
st, err := loadRunState(wsDir)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("load run %s: %w", runID, err)
|
||
}
|
||
gate, err := loadGate(wsDir)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if gate == nil {
|
||
return nil, fmt.Errorf("run %s has no pending human request", runID)
|
||
}
|
||
if decision.RequestID != "" && decision.RequestID != gate.Request.ID {
|
||
// 幂等:回应已处理过的请求 → 返回当前状态。
|
||
if gate.Request.Status != "pending" {
|
||
return e.Inspect(runID)
|
||
}
|
||
return nil, fmt.Errorf("request id mismatch: expected %s", gate.Request.ID)
|
||
}
|
||
|
||
req := &gate.Request
|
||
if strings.TrimSpace(decision.Choice) == "" && strings.TrimSpace(decision.Answer) != "" && choiceHoldKind(req.Kind) {
|
||
return e.noteAndKeepHold(st, gate, decision)
|
||
}
|
||
req.Status = "answered"
|
||
now := time.Now()
|
||
req.AnsweredAt = &now
|
||
if err := st.saveGate(gate); err != nil {
|
||
return nil, err
|
||
}
|
||
e.deps.Sink.Emit(Event{Type: EvHumanResponded, RunID: runID, Text: firstNonEmpty(decision.Choice, decision.Answer),
|
||
Data: marshalJSON(decision), At: time.Now()})
|
||
|
||
res, err := e.applyDecision(ctx, st, gate, decision)
|
||
if err != nil {
|
||
req.Status = "pending"
|
||
req.AnsweredAt = nil
|
||
_ = st.saveGate(gate)
|
||
return nil, err
|
||
}
|
||
if err := clearSameGate(st, req.ID); err != nil {
|
||
return nil, err
|
||
}
|
||
return res, nil
|
||
}
|
||
|
||
func choiceHoldKind(kind HumanRequestKind) bool {
|
||
switch kind {
|
||
case HitlConfirmation, HitlPermission, HitlQualityReview:
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func (e *SquadEngine) noteAndKeepHold(st *runState, gate *hitlGate, decision RespondDecision) (*RunResult, error) {
|
||
if err := st.addSteering(decision.Answer); err != nil {
|
||
return nil, err
|
||
}
|
||
gate.Request.Status = "pending"
|
||
gate.Request.AnsweredAt = nil
|
||
if err := st.saveGate(gate); err != nil {
|
||
return nil, err
|
||
}
|
||
return heldResult(st, gate), nil
|
||
}
|
||
|
||
func clearSameGate(st *runState, requestID string) error {
|
||
current, err := loadGate(st.Meta.WorkDir)
|
||
if err != nil || current == nil || current.Request.ID != requestID {
|
||
return err
|
||
}
|
||
return st.saveGate(nil)
|
||
}
|
||
|
||
func (e *SquadEngine) applyDecision(ctx context.Context, st *runState, gate *hitlGate, decision RespondDecision) (*RunResult, error) {
|
||
req := &gate.Request
|
||
now := time.Now()
|
||
drv := &flowDriver{opts: e.deps}
|
||
switch req.Kind {
|
||
case HitlClarification:
|
||
return e.respondClarification(ctx, st, gate, decision)
|
||
case HitlConfirmation:
|
||
if req.Reason == "interrupted" {
|
||
return e.respondInterrupted(ctx, st, req, decision)
|
||
}
|
||
if req.StepID != "" && gate.Plan == nil {
|
||
return e.respondStepApproval(ctx, st, req, decision)
|
||
}
|
||
switch strings.ToLower(decision.Choice) {
|
||
case "approve":
|
||
if gate.Plan != nil {
|
||
if err := e.materializeWorkspace(&st.Meta, st, gate.Plan); err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
if e.isLeaderRun(st) {
|
||
e.initLeaderState(st)
|
||
}
|
||
st.Meta.Status = string(RunExecuting)
|
||
_ = st.save()
|
||
return e.resumeAfterHold(ctx, st)
|
||
default: // abort / reject
|
||
return e.cancelRun(st, "人工拒绝计划"+appendAnswer(decision)), nil
|
||
}
|
||
case HitlGap:
|
||
switch strings.ToLower(decision.Choice) {
|
||
case "resolved", "":
|
||
answer := decision.Answer
|
||
for _, g := range st.BB.UnresolvedBlockingGaps() {
|
||
st.BB.ResolveGapByID(g.ID, answerFor(g, answer))
|
||
}
|
||
if answer != "" {
|
||
st.Clarifications = append(st.Clarifications, "缺口补充:"+answer)
|
||
}
|
||
case "skip":
|
||
for i := range st.BB.Gaps {
|
||
st.BB.Gaps[i].Blocking = false
|
||
}
|
||
case "abort":
|
||
return e.cancelRun(st, "人工中止(阻塞缺口)"), nil
|
||
default:
|
||
return nil, fmt.Errorf("unknown choice %q for gap request", decision.Choice)
|
||
}
|
||
st.Meta.Status = string(RunExecuting)
|
||
_ = st.save()
|
||
return e.resumeAfterHold(ctx, st)
|
||
case HitlEscalation:
|
||
switch strings.ToLower(decision.Choice) {
|
||
case "abort":
|
||
return e.cancelRun(st, "人工中止(leader 升级问人)"), nil
|
||
default: // 自由文本回答 = 人的决定
|
||
answer := strings.TrimSpace(decision.Answer)
|
||
if answer == "" {
|
||
for _, o := range gate.Request.Options {
|
||
if strings.EqualFold(o.Value, decision.Choice) {
|
||
answer = o.Label
|
||
}
|
||
}
|
||
}
|
||
if answer != "" {
|
||
st.Clarifications = append(st.Clarifications, "升级问答:"+answer)
|
||
st.BB.Facts = append(st.BB.Facts, FactItem{
|
||
ID: fmt.Sprintf("fact_%d", len(st.BB.Facts)+1), Claim: answer, Source: "human",
|
||
})
|
||
if st.Meta.WorkDir != "" {
|
||
_ = e.ws.UpdateGoalFile(st.Meta.WorkDir, &st.Meta.Goal)
|
||
}
|
||
}
|
||
st.Meta.Status = string(RunExecuting)
|
||
_ = st.save()
|
||
return e.resumeAfterHold(ctx, st)
|
||
}
|
||
case HitlAdjustment:
|
||
switch strings.ToLower(decision.Choice) {
|
||
case "approve":
|
||
applyAdjustment(st, gate.Steps, gate.SkipIDs)
|
||
case "reject", "":
|
||
// 忽略调整
|
||
case "abort":
|
||
return e.cancelRun(st, "人工中止(拒绝动态调整)"), nil
|
||
default:
|
||
return nil, fmt.Errorf("unknown choice %q for adjustment request", decision.Choice)
|
||
}
|
||
st.Meta.Status = string(RunExecuting)
|
||
_ = st.save()
|
||
return e.resumeAfterHold(ctx, st)
|
||
case HitlPermission:
|
||
switch strings.ToLower(decision.Choice) {
|
||
case "approve":
|
||
// 授权记到角色(gate 上下文带角色名)。
|
||
role := gate.Request.StepID
|
||
for _, s := range st.Plan.Workflow.Steps {
|
||
if s.ID == gate.Request.StepID {
|
||
role = s.Role
|
||
}
|
||
}
|
||
st.Approved["role:"+role] = true
|
||
st.Meta.Status = string(RunExecuting)
|
||
_ = st.save()
|
||
return e.resumeAfterHold(ctx, st)
|
||
case "reject":
|
||
// 步骤失败(关键性语义由 failStep 处理)。
|
||
for i := range st.Plan.Workflow.Steps {
|
||
if st.Plan.Workflow.Steps[i].ID == gate.Request.StepID {
|
||
st.Plan.Workflow.Steps[i].Status = "failed"
|
||
st.Plan.Workflow.Steps[i].Error = "permission denied by human"
|
||
st.Meta.Status = string(RunExecuting)
|
||
_ = st.save()
|
||
out, _, err := drv.failStepResume(ctx, st, i)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
e.finalizeEvent(out)
|
||
return out, nil
|
||
}
|
||
}
|
||
return e.cancelRun(st, "权限被拒绝且步骤不可定位"), nil
|
||
default:
|
||
return e.cancelRun(st, "人工中止(权限审批)"), nil
|
||
}
|
||
case HitlQualityReview:
|
||
switch strings.ToLower(decision.Choice) {
|
||
case "approve":
|
||
return e.manualPass(st, decision)
|
||
case "retry":
|
||
// 追加一次修复预算并重跑交付步骤。
|
||
st.RepairBudgetRemaining++
|
||
st.Meta.Status = string(RunExecuting)
|
||
_ = st.save()
|
||
deliverStepID := lastCompletedStepID(st)
|
||
out, _, err := drv.rerunForRepair(ctx, st, deliverStepID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
e.finalizeEvent(out)
|
||
return out, nil
|
||
default:
|
||
st.Meta.Status = string(RunFailed)
|
||
st.Meta.CompletedAt = &now
|
||
wf := st.Workflow()
|
||
wf.Status = "failed"
|
||
st.setWorkflow(wf)
|
||
_ = st.save()
|
||
e.deps.Sink.Emit(Event{Type: EvRunFailed, RunID: st.Meta.ID, Text: "人工裁决质检失败", At: time.Now()})
|
||
return &RunResult{RunID: st.Meta.ID, Status: RunFailed, Plan: planPtr(st),
|
||
Error: "quality review rejected by human", WorkDir: st.Meta.WorkDir}, nil
|
||
}
|
||
default:
|
||
return nil, fmt.Errorf("unsupported request kind %q", req.Kind)
|
||
}
|
||
}
|
||
|
||
// respondClarification 澄清回应:答案并入目标后重新规划。
|
||
func (e *SquadEngine) respondClarification(ctx context.Context, st *runState, gate *hitlGate, decision RespondDecision) (*RunResult, error) {
|
||
choice := strings.ToLower(decision.Choice)
|
||
if choice == "abort" {
|
||
return e.cancelRun(st, "人工中止(澄清阶段)"), nil
|
||
}
|
||
answer := strings.TrimSpace(decision.Answer)
|
||
if answer == "" && len(gate.Request.Options) > 0 {
|
||
// 无自由文本时尝试取选项 Label。
|
||
for _, o := range gate.Request.Options {
|
||
if strings.EqualFold(o.Value, decision.Choice) {
|
||
answer = o.Label
|
||
}
|
||
}
|
||
}
|
||
if answer != "" {
|
||
st.Clarifications = append(st.Clarifications, answer)
|
||
st.Meta.Goal.Text = st.Meta.Goal.Text + "\n【人工澄清】" + answer
|
||
if st.Meta.WorkDir != "" {
|
||
_ = e.ws.UpdateGoalFile(st.Meta.WorkDir, &st.Meta.Goal)
|
||
}
|
||
}
|
||
if choice == "" || choice == "continue" || choice == "minimal" {
|
||
if choice == "minimal" {
|
||
// 人工选择最小编制:引导注册 4 个通用角色(backend 用运行默认)。
|
||
e.bootstrapMinimalSquad(st)
|
||
}
|
||
st.Meta.Status = string(RunPlanning)
|
||
_ = st.save()
|
||
runOpts := RunOptions{TaskKey: st.Meta.Metadata["task_key"]}
|
||
return e.planAndExecute(ctx, st, runOpts)
|
||
}
|
||
return e.cancelRun(st, "人工中止(澄清阶段)"), nil
|
||
}
|
||
|
||
// bootstrapMinimalSquad 注册通用最小编制(organizer/specialist/writer/leader)。
|
||
func (e *SquadEngine) bootstrapMinimalSquad(st *runState) {
|
||
backend := st.Meta.Backend
|
||
if backend == "" {
|
||
backend = e.opts.DefaultBackend
|
||
}
|
||
for _, spec := range []RoleSpec{
|
||
{Role: "organizer", Name: "梳理专员", Backend: backend, Keywords: []string{"整理", "梳理", "事实"},
|
||
Responsibilities: []string{"梳理目标背景与输入,回填事实与缺口"}},
|
||
{Role: "specialist", Name: "专项专员", Backend: backend, Keywords: []string{"分析", "推演", "方案"},
|
||
Responsibilities: []string{"围绕目标开展专业分析与推演"}},
|
||
{Role: "writer", Name: "撰写专员", Backend: backend, Keywords: []string{"撰写", "成文", "报告"},
|
||
Responsibilities: []string{"汇总产出并撰写交付物"}},
|
||
{Role: "leader", Name: "审核专员", Backend: backend, Keywords: []string{"审核", "终审", "把关"},
|
||
Responsibilities: []string{"一致性终审与交付把关"}},
|
||
} {
|
||
_ = e.opts.Registry.Register(spec)
|
||
}
|
||
}
|
||
|
||
// manualPass 人工放行质检:标记 ManualPass 并收口。
|
||
func (e *SquadEngine) manualPass(st *runState, decision RespondDecision) (*RunResult, error) {
|
||
report := e.deps.Checker.Check(CheckRequest{
|
||
RunID: st.Meta.ID, Workspace: st.Meta.WorkDir, Deliverable: "output.md",
|
||
Plan: planPtr(st), Squad: &st.Plan.Squad, Template: st.Template,
|
||
})
|
||
report.ManualPass = true
|
||
note := strings.TrimSpace(decision.Answer)
|
||
if note != "" {
|
||
report.Violations = append(report.Violations, "人工放行说明:"+note)
|
||
}
|
||
status := RunCompleted
|
||
if countStatus(st, "failed") > 0 || countStatus(st, "skipped") > 0 {
|
||
status = RunDegraded
|
||
}
|
||
now := time.Now()
|
||
st.Meta.Status = string(status)
|
||
st.Meta.CompletedAt = &now
|
||
wf := st.Workflow()
|
||
wf.Status = string(status)
|
||
st.setWorkflow(wf)
|
||
_ = st.save()
|
||
manifest := buildManifest(st, "output.md", *report)
|
||
_ = writeJSON(filepath.Join(st.Meta.WorkDir, "manifest.json"), manifest)
|
||
e.deps.Sink.Emit(Event{Type: EvRunCompleted, RunID: st.Meta.ID, Text: string(status), At: time.Now()})
|
||
return &RunResult{RunID: st.Meta.ID, Status: status, Plan: planPtr(st),
|
||
Deliverable: "output.md", Quality: report, WorkDir: st.Meta.WorkDir,
|
||
Summary: summarize(st, *report, status) + "(质检人工放行)"}, nil
|
||
}
|
||
|
||
func (e *SquadEngine) cancelRun(st *runState, reason string) *RunResult {
|
||
now := time.Now()
|
||
st.Meta.Status = string(RunCancelled)
|
||
st.Meta.CompletedAt = &now
|
||
wf := st.Workflow()
|
||
wf.Status = "cancelled"
|
||
st.setWorkflow(wf)
|
||
_ = st.save()
|
||
e.deps.Sink.Emit(Event{Type: EvRunCancelled, RunID: st.Meta.ID, Text: reason, At: time.Now()})
|
||
return &RunResult{RunID: st.Meta.ID, Status: RunCancelled, Plan: planPtr(st),
|
||
Summary: reason, WorkDir: st.Meta.WorkDir}
|
||
}
|
||
|
||
// failStepResume 权限拒绝后的失败步骤处理(关键失败/非关键降级)。
|
||
func (d *flowDriver) failStepResume(ctx context.Context, st *runState, idx int) (*RunResult, *hitlGate, error) {
|
||
return d.failStep(ctx, st, idx)
|
||
}
|
||
|
||
func (e *SquadEngine) respondStepApproval(ctx context.Context, st *runState, req *HumanRequest, decision RespondDecision) (*RunResult, error) {
|
||
switch strings.ToLower(decision.Choice) {
|
||
case "approve":
|
||
st.Approved["step:"+req.StepID] = true
|
||
case "skip":
|
||
if !markStep(st, req.StepID, "skipped", "") {
|
||
return nil, fmt.Errorf("step %s not found", req.StepID)
|
||
}
|
||
default:
|
||
return e.cancelRun(st, "人工中止(步骤审批)"), nil
|
||
}
|
||
st.Meta.Status = string(RunExecuting)
|
||
if err := st.save(); err != nil {
|
||
return nil, err
|
||
}
|
||
return e.resumeAfterHold(ctx, st)
|
||
}
|
||
|
||
func (e *SquadEngine) respondInterrupted(ctx context.Context, st *runState, req *HumanRequest, decision RespondDecision) (*RunResult, error) {
|
||
switch strings.ToLower(decision.Choice) {
|
||
case "retry":
|
||
if !markStep(st, req.StepID, "pending", "") {
|
||
return nil, fmt.Errorf("step %s not found", req.StepID)
|
||
}
|
||
case "skip":
|
||
if !markStep(st, req.StepID, "skipped", "") {
|
||
return nil, fmt.Errorf("step %s not found", req.StepID)
|
||
}
|
||
default:
|
||
return e.cancelRun(st, "人工中止(中断恢复)"), nil
|
||
}
|
||
st.Meta.Status = string(RunExecuting)
|
||
if err := st.save(); err != nil {
|
||
return nil, err
|
||
}
|
||
return e.resumeAfterHold(ctx, st)
|
||
}
|
||
|
||
func markStep(st *runState, id, status, errText string) bool {
|
||
for i := range st.Plan.Workflow.Steps {
|
||
if st.Plan.Workflow.Steps[i].ID != id {
|
||
continue
|
||
}
|
||
st.Plan.Workflow.Steps[i].Status = status
|
||
st.Plan.Workflow.Steps[i].Error = errText
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
// recoverInterrupted 处理崩溃时停在 running 的步骤。
|
||
// interactive 挂起确认,不自动再执行;auto 允许一次重试;disabled 直接失败。
|
||
func (e *SquadEngine) recoverInterrupted(ctx context.Context, st *runState) (*RunResult, error) {
|
||
for i := range st.Plan.Workflow.Steps {
|
||
step := st.Plan.Workflow.Steps[i]
|
||
if step.Status != "running" {
|
||
continue
|
||
}
|
||
switch st.policy().mode() {
|
||
case HitlDisabled:
|
||
st.Plan.Workflow.Steps[i].Status = "failed"
|
||
st.Plan.Workflow.Steps[i].Error = "interrupted"
|
||
if err := st.save(); err != nil {
|
||
return nil, err
|
||
}
|
||
drv := &flowDriver{opts: e.deps}
|
||
out, _, err := drv.failStep(ctx, st, i)
|
||
return out, err
|
||
case HitlAuto:
|
||
if step.Attempts >= 2 {
|
||
st.Plan.Workflow.Steps[i].Status = "failed"
|
||
st.Plan.Workflow.Steps[i].Error = "interrupted twice"
|
||
if err := st.save(); err != nil {
|
||
return nil, err
|
||
}
|
||
drv := &flowDriver{opts: e.deps}
|
||
out, _, err := drv.failStep(ctx, st, i)
|
||
return out, err
|
||
}
|
||
st.Plan.Workflow.Steps[i].Status = "pending"
|
||
if err := st.save(); err != nil {
|
||
return nil, err
|
||
}
|
||
return nil, nil
|
||
default:
|
||
drv := &flowDriver{opts: e.deps}
|
||
out, _, err := drv.hold(st, HitlConfirmation, step.ID, step.Role,
|
||
fmt.Sprintf("步骤「%s」在执行中中断。如何继续?", step.Title),
|
||
[]HumanOption{
|
||
{Value: "retry", Label: "重试"},
|
||
{Value: "skip", Label: "跳过该步骤"},
|
||
{Value: "abort", Label: "中止运行"},
|
||
},
|
||
"interrupted", "interrupted", nil)
|
||
return out, err
|
||
}
|
||
}
|
||
return nil, nil
|
||
}
|
||
|
||
// Resume 恢复运行:有挂起则返回挂起状态;未完成则继续推进(重启恢复入口)。
|
||
func (e *SquadEngine) Resume(ctx context.Context, runID string) (*RunResult, error) {
|
||
release, err := e.acquire(ctx, runID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer release()
|
||
|
||
wsDir := filepath.Join(e.opts.DataDir, "runs", runID)
|
||
st, err := loadRunState(wsDir)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("load run %s: %w", runID, err)
|
||
}
|
||
if gate, _ := loadGate(wsDir); gate != nil && gate.Request.Status == "pending" {
|
||
return &RunResult{RunID: runID, Status: RunStatus(st.Meta.Status), Plan: planPtr(st),
|
||
Pending: &gate.Request, WorkDir: st.Meta.WorkDir}, nil
|
||
}
|
||
if RunStatus(st.Meta.Status).Terminal() {
|
||
return e.Inspect(runID)
|
||
}
|
||
if out, err := e.recoverInterrupted(ctx, st); out != nil || err != nil {
|
||
return out, err
|
||
}
|
||
return e.resumeAfterHold(ctx, st)
|
||
}
|
||
|
||
// Inspect 查看运行当前状态(含挂起请求与黑板)。
|
||
func (e *SquadEngine) Inspect(runID string) (*RunResult, error) {
|
||
if !ValidID(runID) {
|
||
return nil, invalidID(runID)
|
||
}
|
||
wsDir := filepath.Join(e.opts.DataDir, "runs", runID)
|
||
st, err := loadRunState(wsDir)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("load run %s: %w", runID, err)
|
||
}
|
||
out := &RunResult{
|
||
RunID: runID, Status: RunStatus(st.Meta.Status), Plan: planPtr(st),
|
||
Squad: &st.Plan.Squad, WorkDir: st.Meta.WorkDir, Summary: st.Meta.Status,
|
||
}
|
||
if gate, _ := loadGate(wsDir); gate != nil {
|
||
out.Pending = &gate.Request
|
||
}
|
||
if data, err := os.ReadFile(filepath.Join(wsDir, "output.md")); err == nil && len(data) > 0 {
|
||
out.Deliverable = "output.md"
|
||
}
|
||
if data, err := os.ReadFile(filepath.Join(wsDir, "manifest.json")); err == nil {
|
||
var m Manifest
|
||
if json.Unmarshal(data, &m) == nil {
|
||
out.Quality = &m.QualityReport
|
||
}
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// Blackboard 查看运行黑板。
|
||
func (e *SquadEngine) Blackboard(runID string) (*BlackboardState, error) {
|
||
if !ValidID(runID) {
|
||
return nil, invalidID(runID)
|
||
}
|
||
wsDir := filepath.Join(e.opts.DataDir, "runs", runID)
|
||
return loadBlackboard(wsDir)
|
||
}
|
||
|
||
// ListRuns 列出全部运行(按创建时间倒序)。
|
||
func (e *SquadEngine) ListRuns() ([]RunMetadata, error) {
|
||
runsDir := filepath.Join(e.opts.DataDir, "runs")
|
||
entries, err := os.ReadDir(runsDir)
|
||
if err != nil {
|
||
if os.IsNotExist(err) {
|
||
return nil, nil
|
||
}
|
||
return nil, err
|
||
}
|
||
var out []RunMetadata
|
||
for _, ent := range entries {
|
||
if !ent.IsDir() {
|
||
continue
|
||
}
|
||
st, err := loadRunState(filepath.Join(runsDir, ent.Name()))
|
||
if err != nil {
|
||
continue
|
||
}
|
||
out = append(out, st.Meta)
|
||
}
|
||
sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt.After(out[j].CreatedAt) })
|
||
return out, nil
|
||
}
|
||
|
||
// Pending 返回运行当前挂起请求(无则 nil)。
|
||
func (e *SquadEngine) Pending(runID string) (*HumanRequest, error) {
|
||
if !ValidID(runID) {
|
||
return nil, invalidID(runID)
|
||
}
|
||
gate, err := loadGate(filepath.Join(e.opts.DataDir, "runs", runID))
|
||
if err != nil || gate == nil {
|
||
return nil, err
|
||
}
|
||
return &gate.Request, nil
|
||
}
|
||
|
||
// Steer 向运行中的任务追加人工补充指令:在下一次步骤边界注入当前运行的
|
||
// prompt(步骤间注入,不中断正在执行的步骤),落盘 steering.json。
|
||
func (e *SquadEngine) Steer(runID, text string) error {
|
||
if strings.TrimSpace(text) == "" {
|
||
return fmt.Errorf("steering text is empty")
|
||
}
|
||
release, err := e.acquire(context.Background(), runID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer release()
|
||
|
||
st, err := loadRunState(filepath.Join(e.opts.DataDir, "runs", runID))
|
||
if err != nil {
|
||
return fmt.Errorf("load run %s: %w", runID, err)
|
||
}
|
||
if RunStatus(st.Meta.Status).Terminal() {
|
||
return fmt.Errorf("run %s already terminal (%s)", runID, st.Meta.Status)
|
||
}
|
||
st.Steering = append(st.Steering, strings.TrimSpace(text))
|
||
_ = writeJSON(filepath.Join(st.Meta.WorkDir, "steering.json"), st.Steering)
|
||
e.deps.Sink.Emit(Event{Type: "steered", RunID: runID, Text: truncate(text, 80), At: time.Now()})
|
||
return nil
|
||
}
|
||
|
||
// SetBlackboardField 写入黑板字段(外部事实注入;可满足 field_set 等待条件)。
|
||
func (e *SquadEngine) SetBlackboardField(runID, key, value string) error {
|
||
if strings.TrimSpace(key) == "" {
|
||
return fmt.Errorf("field key is empty")
|
||
}
|
||
release, err := e.acquire(context.Background(), runID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer release()
|
||
|
||
st, err := loadRunState(filepath.Join(e.opts.DataDir, "runs", runID))
|
||
if err != nil {
|
||
return fmt.Errorf("load run %s: %w", runID, err)
|
||
}
|
||
st.BB.Fields[key] = value
|
||
if err := saveBlackboard(st.Meta.WorkDir, &st.BB); err != nil {
|
||
return err
|
||
}
|
||
e.deps.Sink.Emit(Event{Type: "blackboard_field_set", RunID: runID,
|
||
Text: key + "=" + truncate(value, 60), At: time.Now()})
|
||
return nil
|
||
}
|
||
|
||
// SweepWakeups 扫描所有挂起运行:等待条件(run_done/field_set)满足时
|
||
// 合成自动决策续跑。护栏:同一请求自动唤醒次数达 MaxWakeupFires 即自动中止。
|
||
// 由 serve 模式周期调用,也可在任何运行终态后手动调用。返回唤醒数。
|
||
func (e *SquadEngine) SweepWakeups(ctx context.Context) int {
|
||
runs, err := e.ListRuns()
|
||
if err != nil {
|
||
return 0
|
||
}
|
||
woken := 0
|
||
for _, meta := range runs {
|
||
if !RunStatus(meta.Status).IsHeld() {
|
||
continue
|
||
}
|
||
gate, err := loadGate(filepath.Join(e.opts.DataDir, "runs", meta.ID))
|
||
if err != nil || gate == nil || gate.Request.Status != "pending" || gate.Request.WaitCondition == nil {
|
||
continue
|
||
}
|
||
release, err := e.acquire(ctx, meta.ID)
|
||
if err != nil {
|
||
continue
|
||
}
|
||
if e.fireWakeup(ctx, meta.ID) {
|
||
woken++
|
||
}
|
||
release()
|
||
}
|
||
return woken
|
||
}
|
||
|
||
// fireWakeup 在已持有 run 锁时处理一条挂起。只自动放行 gap 与 escalation。
|
||
func (e *SquadEngine) fireWakeup(ctx context.Context, runID string) bool {
|
||
gate, err := loadGate(filepath.Join(e.opts.DataDir, "runs", runID))
|
||
if err != nil || gate == nil || gate.Request.Status != "pending" || gate.Request.WaitCondition == nil {
|
||
return false
|
||
}
|
||
met, fact := e.evaluateWaitCondition(runID, gate.Request.WaitCondition)
|
||
if !met {
|
||
return false
|
||
}
|
||
gate.Request.WakeupFires++
|
||
if err := writeJSON(filepath.Join(e.opts.DataDir, "runs", runID, "pending.json"), gate); err != nil {
|
||
return false
|
||
}
|
||
if gate.Request.WakeupFires >= MaxWakeupFires {
|
||
_, err := e.respondLocked(ctx, runID, RespondDecision{RequestID: gate.Request.ID, Choice: "abort",
|
||
Answer: "自动唤醒次数达上限,自动中止"})
|
||
return err == nil
|
||
}
|
||
switch gate.Request.Kind {
|
||
case HitlGap, HitlEscalation:
|
||
decision := RespondDecision{RequestID: gate.Request.ID, Choice: "resolved", Answer: "[自动唤醒] " + fact}
|
||
if _, err := e.respondLocked(ctx, runID, decision); err != nil {
|
||
return false
|
||
}
|
||
e.deps.Sink.Emit(Event{Type: "wakeup_fired", RunID: runID, Text: fact, At: time.Now()})
|
||
return true
|
||
default:
|
||
e.deps.Sink.Emit(Event{Type: "wakeup_held", RunID: runID, Text: fact, At: time.Now()})
|
||
return false
|
||
}
|
||
}
|
||
|
||
// evaluateWaitCondition 评估等待条件;返回是否满足与事实描述。
|
||
func (e *SquadEngine) evaluateWaitCondition(runID string, cond *WaitCondition) (bool, string) {
|
||
switch cond.Type {
|
||
case "run_done":
|
||
if cond.RunID == "" || cond.RunID == runID {
|
||
return false, "" // 自引用不触发
|
||
}
|
||
target, err := e.Inspect(cond.RunID)
|
||
if err != nil {
|
||
return false, ""
|
||
}
|
||
if target.Status.Terminal() {
|
||
return true, fmt.Sprintf("等待的运行 %s 已进入终态 %s(唤醒报告事实,非业务完成)", cond.RunID, target.Status)
|
||
}
|
||
case "field_set":
|
||
if cond.Field == "" {
|
||
return false, ""
|
||
}
|
||
bb, err := e.Blackboard(runID)
|
||
if err != nil {
|
||
return false, ""
|
||
}
|
||
if v, ok := bb.Fields[cond.Field]; ok && strings.TrimSpace(v) != "" {
|
||
return true, fmt.Sprintf("黑板字段 %s 已设置:%s", cond.Field, truncate(v, 80))
|
||
}
|
||
}
|
||
return false, ""
|
||
}
|
||
|
||
func (e *SquadEngine) finalizeEvent(out *RunResult) {
|
||
if out == nil {
|
||
return
|
||
}
|
||
switch out.Status {
|
||
case RunCompleted, RunDegraded:
|
||
e.deps.Sink.Emit(Event{Type: EvRunCompleted, RunID: out.RunID, Text: out.Summary, At: time.Now()})
|
||
case RunFailed:
|
||
e.deps.Sink.Emit(Event{Type: EvRunFailed, RunID: out.RunID, Text: out.Error, At: time.Now()})
|
||
}
|
||
}
|
||
|
||
// ---- 小工具 ----
|
||
|
||
func sha256File(path string) string {
|
||
data, err := os.ReadFile(path)
|
||
if err != nil {
|
||
return ""
|
||
}
|
||
sum := sha256.Sum256(data)
|
||
return hex.EncodeToString(sum[:])
|
||
}
|
||
|
||
func marshalJSON(v any) json.RawMessage {
|
||
data, err := json.Marshal(v)
|
||
if err != nil {
|
||
return json.RawMessage(`{}`)
|
||
}
|
||
return data
|
||
}
|
||
|
||
func bpPtr(bp Blueprint) *Blueprint {
|
||
if bp.Key == "" {
|
||
return nil
|
||
}
|
||
return &bp
|
||
}
|
||
|
||
func planPtr2(p *SquadPlan) *WorkflowPlan {
|
||
wf := p.Workflow
|
||
return &wf
|
||
}
|
||
|
||
func answerFor(g GapItem, answer string) string {
|
||
if answer == "" {
|
||
return "人工确认按现状继续"
|
||
}
|
||
return answer
|
||
}
|
||
|
||
func appendAnswer(d RespondDecision) string {
|
||
if strings.TrimSpace(d.Answer) == "" {
|
||
return ""
|
||
}
|
||
return "(" + d.Answer + ")"
|
||
}
|
||
|
||
func lastCompletedStepID(st *runState) string {
|
||
id := ""
|
||
for _, s := range st.Plan.Workflow.Steps {
|
||
if s.Status == "completed" {
|
||
id = s.ID
|
||
}
|
||
}
|
||
return id
|
||
}
|