Files
Agent-Squad/squad/engine.go
T
weichenandCursor 9537321782 Close the squad P1 gaps the audit listed for this batch
Why: parallel groups dropped a hold, HITL timeouts were unused, the planner failed closed on any model error, and event sequence numbers restarted at 1.
Evidence: GOWORK=off go test -count=1 -race ./squad/ ./squadintent/ ./store/ ./platform/ passed, including TestR2ExpiredDecisionUsesCancel, TestR2IntentStepSkipsRoleRegistry, TestR2PlannerFallsBackAndCapsSteps, TestR2TaskFileUsesLatestBlackboard, TestR2FanoutCancelDoesNotPanic, TestR2EventSeqContinuesAfterRestart, TestR2JournalModeWAL, and TestR2GateReloadSeesNewIntent.
Impact: a failed or held parallel sibling cancels the rest; expired requests synthesize cancel or fail; the intent gate follows Registry.Reload.
Refs: Q6, Q10, Q11, Q12, Q13, Q17, Q18, Q19, I9
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-03 13:10:39 +00:00

1260 lines
40 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}
// SweepExpired 把超过 HITL 时限的挂起请求合成 fail 或 cancel 决策。
func (e *SquadEngine) SweepExpired(ctx context.Context, now time.Time) int {
runs, err := e.ListRuns()
if err != nil {
return 0
}
n := 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" {
continue
}
decision, due := expiredDecision(gate.Request, e.deps.HitlPolicy, now)
if !due {
continue
}
if _, err := e.Respond(ctx, meta.ID, decision); err == nil {
n++
}
}
return n
}
// 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
}