Why: Q2 acceptance requires leader runs to detect stuck delegation loops and cap replans instead of spinning to the turn limit, and a mailbox primitive whose messages can never resolve a pending permission approval (acceptance 3). Evidence: squad conformance tests (leader_mailbox_conformance_test.go): ledger stuck->replan, replan cap finalize, mailbox delivery after hold, mailbox-cannot-approve-permission (structural + actor second defense), post guards; full suite green with -race. Impact: LeaderState gains Progress/StuckTurns/Replans/MaxReplans/ReplanNote (persisted in leader.json); RunState gains Mailbox (persisted in runstate.json); RespondDecision gains Actor - non-human actors are rejected on choice gates; step briefs and leader task briefs include pending mail. Refs: Q2 plan WP2 (2026-10-07-agent-stack-q2q3-plans.md); task card .task/feature-agent-stack-q2.md
1384 lines
46 KiB
Go
1384 lines
46 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
|
||
// Store 运行状态持久化端口(Q1 WP4/WP5);nil 回落 FileStore。
|
||
// 注入 SQLiteStore 后运行状态进 SQLite(装配期一次,跨 run 一致)。
|
||
Store Store
|
||
// 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
|
||
// Store 运行状态持久化端口(Q1 WP4);nil 回落 FileStore(装配期注入替换实现)。
|
||
Store Store
|
||
}
|
||
|
||
// storeOr 返回注入的 Store,缺省 FileStore。
|
||
func (d engineDeps) storeOr() Store {
|
||
if d.Store != nil {
|
||
return d.Store
|
||
}
|
||
return FileStore{}
|
||
}
|
||
|
||
// loadRun 经端口加载运行状态,并让它携带同一端口(后续 save 自然走同一实现)。
|
||
// runDir 是运行的工作区目录(所有 run 级 JSON 的根)。
|
||
func (e *SquadEngine) runDir(runID string) string {
|
||
return filepath.Join(e.opts.DataDir, "runs", runID)
|
||
}
|
||
|
||
func (e *SquadEngine) loadRun(wsDir string) (*RunState, error) {
|
||
st, err := e.deps.storeOr().LoadRun(wsDir)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
st.store = e.deps.storeOr()
|
||
return st, nil
|
||
}
|
||
|
||
// 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"`
|
||
// CheckpointMode superstep 边界快照档位(Q1 WP4):sync(默认)| async | on_exit。
|
||
CheckpointMode string `json:"checkpoint_mode,omitempty"`
|
||
// Topology 显式拓扑名(Q2;优先于 Blueprint.Topology;空 = standard 图)。
|
||
// 未注册名 / primitive 单跑 / driver 未点亮均 fail-closed。
|
||
Topology string `json:"topology,omitempty"`
|
||
// Deadline 运行级截止时刻(Q2 long_running):superstep 边界检查,
|
||
// 超期把未完步骤置 skipped 并降级收口。零值 = 不设限。
|
||
Deadline time.Time `json:"deadline,omitempty"`
|
||
// MaxReplans leader 模式的重规划上限(Q2;与 MaxLeaderTurns 独立)。
|
||
// 0 = 默认 DefaultMaxReplans。
|
||
MaxReplans int `json:"max_replans,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, Store: opts.Store,
|
||
}
|
||
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)
|
||
}
|
||
}
|
||
if runOpts.CheckpointMode != "" {
|
||
meta.Metadata["checkpoint_mode"] = runOpts.CheckpointMode
|
||
}
|
||
if runOpts.Topology != "" {
|
||
meta.Metadata["topology"] = runOpts.Topology
|
||
}
|
||
if !runOpts.Deadline.IsZero() {
|
||
// RFC3339Nano:秒级 RFC3339 会截断亚秒精度,短截止会被立刻判超期。
|
||
meta.Metadata["deadline"] = runOpts.Deadline.UTC().Format(time.RFC3339Nano)
|
||
}
|
||
if runOpts.MaxReplans > 0 {
|
||
meta.Metadata["max_replans"] = fmt.Sprintf("%d", runOpts.MaxReplans)
|
||
}
|
||
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, store: e.deps.storeOr()}
|
||
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)
|
||
// 拓扑解析(Q2):RunOptions 优先,Blueprint 兜底。
|
||
if meta.Metadata["topology"] == "" && bpMatch.Topology != "" {
|
||
meta.Metadata["topology"] = bpMatch.Topology
|
||
}
|
||
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
|
||
}
|
||
// 拓扑校验与分发(Q2):未注册名 / primitive 单跑 / driver 未点亮 fail-closed;
|
||
// leader 拓扑映射到既有 Mode 委派循环(不新造执行面)。
|
||
if topo := meta.Metadata["topology"]; topo != "" {
|
||
kind, terr := checkTopology(topo)
|
||
if terr != nil {
|
||
return e.failNew(meta, terr)
|
||
}
|
||
if kind == TopologyMode && topo == TopologyLeader {
|
||
if meta.Metadata["mode"] == "" {
|
||
meta.Metadata["mode"] = "leader"
|
||
}
|
||
}
|
||
e.deps.Sink.Emit(Event{Type: EvPhase, RunID: meta.ID, Text: "拓扑 " + topo, At: time.Now()})
|
||
}
|
||
// 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
|
||
}
|
||
}
|
||
maxReplans := DefaultMaxReplans
|
||
if v := st.Meta.Metadata["max_replans"]; v != "" {
|
||
if n := parseIntDefault(v, DefaultMaxReplans); n > 0 {
|
||
maxReplans = n
|
||
}
|
||
}
|
||
st.Leader = &LeaderState{LeaderRole: leaderRole, MaxTurns: maxTurns, MaxReplans: maxReplans}
|
||
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
|
||
_, err := e.ws.PrepareWorkspace(&st.Meta, plan)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
st.Template = plan.Template
|
||
// 新运行的黑板在内存初始化;持久化由后续 st.save() 经 Store 端口完成。
|
||
st.BB = BlackboardState{Fields: map[string]string{}}
|
||
} 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 := e.loadRun(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
|
||
// 非 human actor 不得决议 choice 门(Q2 验收三的第二道防线:邮箱/代理
|
||
// 消息即便伪造 Respond 调用也清不掉 permission/confirmation/quality 门)。
|
||
if decision.Actor != "" && decision.Actor != ActorHuman && choiceHoldKind(req.Kind) &&
|
||
strings.TrimSpace(decision.Choice) != "" {
|
||
e.deps.Sink.Emit(Event{Type: EvPhase, RunID: runID,
|
||
Text: fmt.Sprintf("拒绝非人工 actor(%s) 决议 %s 门(请求 %s)", decision.Actor, req.Kind, req.ID), At: time.Now()})
|
||
return e.noteAndKeepHold(st, gate, RespondDecision{
|
||
RequestID: decision.RequestID,
|
||
Answer: fmt.Sprintf("[blocked] 非人工 actor(%s) 尝试决议本门,已拒绝;等待人工裁决。", decision.Actor),
|
||
})
|
||
}
|
||
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":
|
||
// 追加一次修复预算并重跑交付步骤。累计追加有上限(Q26),
|
||
// 上限内才加——无上限的人工 retry 会把运行变成永动机。
|
||
term := e.terminationOf(st)
|
||
if st.RepairBudgetGranted >= term.MaxRepairGranted {
|
||
drv.emit(st, Event{Type: EvPhase, Text: fmt.Sprintf("人工修复追加已达上限 %d,不再追加(Q26)", term.MaxRepairGranted)})
|
||
return &RunResult{RunID: st.Meta.ID, Status: RunFailed, Plan: planPtr(st),
|
||
Error: fmt.Sprintf("repair budget grants exhausted (%d/%d)", st.RepairBudgetGranted, term.MaxRepairGranted),
|
||
WorkDir: st.Meta.WorkDir}, nil
|
||
}
|
||
st.RepairBudgetRemaining++
|
||
st.RepairBudgetGranted++
|
||
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
|
||
}
|
||
// Q1 WP3 产物校验:步骤在 running 崩溃,但其交付物已全部落盘——
|
||
// 副作用实际发生过,直接判定完成而不是重新执行(副作用次数=1)。
|
||
subDir := filepath.Join(st.Meta.WorkDir, filepath.FromSlash(orDefault(step.SubWorkspace, fmt.Sprintf("tasks/%02d_%s", i+1, step.Role))))
|
||
targets := step.Targets
|
||
if len(targets) == 0 {
|
||
targets = []string{"output.md"}
|
||
}
|
||
if missingTargets(subDir, targets) == nil {
|
||
step.Status = "completed"
|
||
step.Error = ""
|
||
st.Plan.Workflow.Steps[i] = step
|
||
st.mu.Lock()
|
||
_ = mergeStepArtifacts(&st.BB, subDir)
|
||
err := st.save()
|
||
st.mu.Unlock()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
drv := &flowDriver{opts: e.deps}
|
||
drv.emit(st, Event{Type: EvStepCompleted, StepID: step.ID, Role: step.Role,
|
||
Text: fmt.Sprintf("中断恢复:产物齐全,按 attempt %s 判定完成", step.AttemptID)})
|
||
continue
|
||
}
|
||
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 := e.loadRun(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)
|
||
}
|
||
// 图连续性校验(Q1 WP4/WP1 裁定):崩溃期间的检查点图指纹与当前 Steps
|
||
// 重编译不一致 = 图形态被改过(动态调整或外力),拒绝恢复并提示重新规划。
|
||
if err := validateGraphContinuity(st); err != nil {
|
||
return nil, err
|
||
}
|
||
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 := e.loadRun(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 := e.loadRun(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 := e.loadRun(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 := e.loadRun(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 >= e.terminationOf(nil).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
|
||
}
|