Files
Agent-Squad/squad/engine.go
T
weichen acbe744142
ci / vet-and-test (pull_request) Successful in 26s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Add task-board driver with claim/lease/complete and 12-topology aggregate (Q2 WP4)
Why: Q2 acceptance 1 requires a scripted-backend conformance for every one of
the 12 builtin topologies; task_board was the last unlit driver.

Evidence: wp4_conformance_test.go: diamond-dependency board respects the
dependency gate in claim order; orphan lease (claimed + expired) re-arms on
driver re-entry and re-claims with Attempts=2 via Resume; non-critical task
failure cascades dependents to skipped and finishes degraded; fail-closed
when tasks remain unclosable. TestTopologyConformance_allTwelve runs all 12
names (10 runnable to completion, handoff/mailbox rejected as primitives).
Full suite green with -race.

Impact: task_board lit in the topology registry (all 12 topologies now
covered); RunState gains Board (persisted in runstate.json); RunOptions gains
TaskBoardLeaseSeconds; leader topology now maps to mode before plan
confirmation so Topology=leader runs no longer hit the confirmation gate.

Refs: Q2 plan WP4 (2026-10-07-agent-stack-q2q3-plans.md); task card
.task/feature-agent-stack-q2.md
2026-10-08 01:29:03 +00:00

1464 lines
50 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
// 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"`
// GroupChatPolicy group_chat 拓扑发言人策略(Q2):rotate(默认)| moderator。
GroupChatPolicy string `json:"group_chat_policy,omitempty"`
// GroupChatModerator 主持人角色 key(moderator 策略必填,须在名册内)。
GroupChatModerator string `json:"group_chat_moderator,omitempty"`
// GroupChatMaxTurns 群聊轮次预算(0 = DefaultMaxGroupChatTurns)。
GroupChatMaxTurns int `json:"group_chat_max_turns,omitempty"`
// ReviewVerifyRole review 拓扑核实角色 key(默认 "verifier")。
ReviewVerifyRole string `json:"review_verify_role,omitempty"`
// ReviewTarget 被评审材料(默认取 context/goal.md)。
ReviewTarget string `json:"review_target,omitempty"`
// TaskBoardLeaseSeconds task_board 任务租约时限(秒;0 = 默认 600)。
// 孤儿租约(claimed 且过期)在驱动重入时再武装回 open。
TaskBoardLeaseSeconds int `json:"task_board_lease_seconds,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
// leader 拓扑映射到委派循环须发生在计划确认之前(确认门按
// mode=leader 豁免);后置映射会让 leader 运行误触计划确认。
if runOpts.Topology == TopologyLeader && meta.Metadata["mode"] == "" {
meta.Metadata["mode"] = "leader"
}
}
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)
}
if runOpts.GroupChatPolicy != "" {
meta.Metadata["group_chat_policy"] = runOpts.GroupChatPolicy
}
if runOpts.GroupChatModerator != "" {
meta.Metadata["group_chat_moderator"] = runOpts.GroupChatModerator
}
if runOpts.GroupChatMaxTurns > 0 {
meta.Metadata["group_chat_max_turns"] = fmt.Sprintf("%d", runOpts.GroupChatMaxTurns)
}
if runOpts.ReviewVerifyRole != "" {
meta.Metadata["review_verify_role"] = runOpts.ReviewVerifyRole
}
if runOpts.ReviewTarget != "" {
meta.Metadata["review_target"] = runOpts.ReviewTarget
}
if runOpts.TaskBoardLeaseSeconds > 0 {
meta.Metadata["task_board_lease_seconds"] = fmt.Sprintf("%d", runOpts.TaskBoardLeaseSeconds)
}
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
}
// driver 类拓扑(Q2 WP3+):按名分发专用驱动(review/group_chat;
// task_board 属 WP4,未点亮前 checkTopology 已 fail-closed)。
if out, handled, derr := e.dispatchTopologyDriver(ctx, st); handled {
if derr != nil {
return nil, derr
}
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
}
// driver 类拓扑续跑:驱动自身幂等(review 已完成 finder 不重跑;
// group_chat 按已落盘轮次续转)。
if out, handled, err := e.dispatchTopologyDriver(ctx, st); handled {
if err != nil {
return nil, err
}
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
}
// dispatchTopologyDriver 按运行拓扑分发 driver 类拓扑。
// handled=false 表示非 driver 拓扑(走既有图/模式路径)。
func (e *SquadEngine) dispatchTopologyDriver(ctx context.Context, st *RunState) (*RunResult, bool, error) {
topo := st.Meta.Metadata["topology"]
if topo == "" {
return nil, false, nil
}
spec, ok := LookupTopology(topo)
if !ok || spec.Kind != TopologyDriver {
return nil, false, nil
}
switch topo {
case TopologyReview:
out, err := e.runReviewDriver(ctx, st)
return out, true, err
case TopologyGroupChat:
out, err := e.runGroupChatDriver(ctx, st)
return out, true, err
case TopologyTaskBoard:
out, err := e.runTaskBoardDriver(ctx, st)
return out, true, err
default:
return e.driverFail(st, fmt.Sprintf("topology: %q 驱动未实现(fail-closed)", topo)), true, 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
}