E2E v2 still hit 30min runner timeout because runOneStep ignored ExecuteOptions.Timeout; inject shared KB config in pi-agent E2E test. Co-authored-by: Cursor <cursoragent@cursor.com>
827 lines
24 KiB
Go
827 lines
24 KiB
Go
package squad
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"chat-agent-system/internal/agent"
|
||
"chat-agent-system/internal/stream"
|
||
)
|
||
|
||
// StepEvent 表示小队单步执行生命周期的事件
|
||
type StepEvent struct {
|
||
StepID string `json:"step_id"`
|
||
Role string `json:"role"`
|
||
Title string `json:"title"`
|
||
Mode string `json:"mode"`
|
||
Status string `json:"status"` // started, completed, failed, skipped
|
||
DurationMs int64 `json:"duration_ms,omitempty"`
|
||
Error string `json:"error,omitempty"`
|
||
}
|
||
|
||
// ExecuteOptions 执行调度选项
|
||
type ExecuteOptions struct {
|
||
Timeout time.Duration
|
||
StepNotify func(StepEvent)
|
||
// BudgetPolicy 治理预算策略覆盖(缺省 DefaultBudgetPolicy;A2/A3 任务档位可收紧)
|
||
BudgetPolicy *BudgetPolicy
|
||
}
|
||
|
||
// WorkflowExecutionResult 工作流执行结果
|
||
type WorkflowExecutionResult struct {
|
||
Success bool `json:"success"`
|
||
StepsRun int `json:"steps_run"`
|
||
Deliverable string `json:"deliverable"`
|
||
FinalContent []byte `json:"-"`
|
||
Duration time.Duration `json:"duration"`
|
||
Fields map[string]string `json:"fields,omitempty"`
|
||
ErrorReason string `json:"error_reason,omitempty"`
|
||
}
|
||
|
||
// WorkflowDriver 负责动态工作流调度与沙箱隔离执行
|
||
type WorkflowDriver struct {
|
||
engine *WorkspaceEngine
|
||
evaluator *StepEvaluator
|
||
}
|
||
|
||
func NewWorkflowDriver(engine *WorkspaceEngine) *WorkflowDriver {
|
||
if engine == nil {
|
||
engine = NewWorkspaceEngine()
|
||
}
|
||
return &WorkflowDriver{engine: engine, evaluator: NewStepEvaluator()}
|
||
}
|
||
|
||
// SetEvaluator 配置自定义单步评估器
|
||
func (d *WorkflowDriver) SetEvaluator(ev *StepEvaluator) {
|
||
d.evaluator = ev
|
||
}
|
||
|
||
// LoadWorkflow 读取工作区 workflow.json
|
||
func (d *WorkflowDriver) LoadWorkflow(wsDir string) (*WorkflowPlan, error) {
|
||
p := filepath.Join(wsDir, "workflow.json")
|
||
data, err := os.ReadFile(p)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("read workflow.json: %w", err)
|
||
}
|
||
var wf WorkflowPlan
|
||
if err := json.Unmarshal(data, &wf); err != nil {
|
||
return nil, fmt.Errorf("unmarshal workflow.json: %w", err)
|
||
}
|
||
return &wf, nil
|
||
}
|
||
|
||
// SaveWorkflow 持久化 workflow.json
|
||
func (d *WorkflowDriver) SaveWorkflow(wsDir string, wf *WorkflowPlan) error {
|
||
p := filepath.Join(wsDir, "workflow.json")
|
||
return writeJSON(p, wf)
|
||
}
|
||
|
||
// LoadBlackboard 读取工作区 blackboard.json
|
||
func (d *WorkflowDriver) LoadBlackboard(wsDir string) (*BlackboardState, error) {
|
||
p := filepath.Join(wsDir, "blackboard.json")
|
||
data, err := os.ReadFile(p)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("read blackboard.json: %w", err)
|
||
}
|
||
var bb BlackboardState
|
||
if err := json.Unmarshal(data, &bb); err != nil {
|
||
return nil, fmt.Errorf("unmarshal blackboard.json: %w", err)
|
||
}
|
||
return &bb, nil
|
||
}
|
||
|
||
// SaveBlackboard 保存 blackboard.json
|
||
func (d *WorkflowDriver) SaveBlackboard(wsDir string, bb *BlackboardState) error {
|
||
bb.UpdatedAt = time.Now()
|
||
p := filepath.Join(wsDir, "blackboard.json")
|
||
return writeJSON(p, bb)
|
||
}
|
||
|
||
// AddStep 动态向工作流追加任务步骤并自动构建其子工作区
|
||
func (d *WorkflowDriver) AddStep(wsDir string, step StepSpec) error {
|
||
wf, err := d.LoadWorkflow(wsDir)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
if step.SubWorkspace == "" {
|
||
step.SubWorkspace = fmt.Sprintf("tasks/%02d_%s", len(wf.Steps)+1, step.Role)
|
||
}
|
||
if step.Status == "" {
|
||
step.Status = "pending"
|
||
}
|
||
if step.Mode == "" {
|
||
step.Mode = "execute"
|
||
}
|
||
|
||
// 准备子任务沙箱
|
||
squadCfg, _ := d.loadSquadConfig(wsDir)
|
||
tmpl, _ := os.ReadFile(filepath.Join(wsDir, "template.md"))
|
||
singleStepPlan := WorkflowPlan{Steps: []StepSpec{step}}
|
||
if err := d.engine.buildSubWorkspaces(wsDir, singleStepPlan, squadCfg, string(tmpl)); err != nil {
|
||
return fmt.Errorf("build added sub workspace: %w", err)
|
||
}
|
||
|
||
wf.Steps = append(wf.Steps, step)
|
||
return d.SaveWorkflow(wsDir, wf)
|
||
}
|
||
|
||
// RemoveStep 动态删除未执行的任务步骤
|
||
func (d *WorkflowDriver) RemoveStep(wsDir string, stepID string) error {
|
||
wf, err := d.LoadWorkflow(wsDir)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
idx := -1
|
||
for i, s := range wf.Steps {
|
||
if s.ID == stepID {
|
||
idx = i
|
||
break
|
||
}
|
||
}
|
||
if idx == -1 {
|
||
return fmt.Errorf("step %s not found in workflow", stepID)
|
||
}
|
||
|
||
wf.Steps = append(wf.Steps[:idx], wf.Steps[idx+1:]...)
|
||
return d.SaveWorkflow(wsDir, wf)
|
||
}
|
||
|
||
// UpdateStep 动态修改现有任务步骤(如重写验收标准或切换模式)
|
||
func (d *WorkflowDriver) UpdateStep(wsDir string, step StepSpec) error {
|
||
wf, err := d.LoadWorkflow(wsDir)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
found := false
|
||
for i, s := range wf.Steps {
|
||
if s.ID == step.ID {
|
||
wf.Steps[i] = step
|
||
found = true
|
||
break
|
||
}
|
||
}
|
||
if !found {
|
||
return fmt.Errorf("step %s not found to update", step.ID)
|
||
}
|
||
|
||
// 同步更新子工作区 TASK.md
|
||
squadCfg, _ := d.loadSquadConfig(wsDir)
|
||
tmpl, _ := os.ReadFile(filepath.Join(wsDir, "template.md"))
|
||
_ = d.engine.buildSubWorkspaces(wsDir, WorkflowPlan{Steps: []StepSpec{step}}, squadCfg, string(tmpl))
|
||
|
||
return d.SaveWorkflow(wsDir, wf)
|
||
}
|
||
|
||
// Execute 调度并执行工作区内的全套步骤
|
||
func (d *WorkflowDriver) Execute(ctx context.Context, wsDir string, backend agent.Backend, opts ExecuteOptions) (*WorkflowExecutionResult, error) {
|
||
start := time.Now()
|
||
|
||
wf, err := d.LoadWorkflow(wsDir)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
wf.Status = "in_progress"
|
||
_ = d.SaveWorkflow(wsDir, wf)
|
||
|
||
_ = d.updateWorkspaceStatus(wsDir, "running")
|
||
|
||
timeout := opts.Timeout
|
||
if timeout <= 0 {
|
||
timeout = 30 * time.Minute
|
||
}
|
||
runCtx, cancel := context.WithTimeout(ctx, timeout)
|
||
defer cancel()
|
||
|
||
var mu sync.Mutex
|
||
stepsRun := 0
|
||
var producedFiles []string
|
||
|
||
// 治理内核:加载预算账本(Execute 前检查、后入账;含软硬两级阈值)
|
||
govPolicy := DefaultBudgetPolicy()
|
||
if opts.BudgetPolicy != nil {
|
||
govPolicy = *opts.BudgetPolicy
|
||
}
|
||
ledger := LoadOrCreateLedger(wsDir, govPolicy)
|
||
defer func() { _ = ledger.Save(wsDir) }()
|
||
|
||
i := 0
|
||
for i < len(wf.Steps) {
|
||
step := wf.Steps[i]
|
||
if step.Status == "skipped" {
|
||
i++
|
||
continue
|
||
}
|
||
// 治理:已归档锁定的步骤跳过重跑(完成即冻结;显式 ReopenStep 后方可重新执行)
|
||
if step.Frozen {
|
||
i++
|
||
continue
|
||
}
|
||
|
||
if step.Parallel {
|
||
var parallelGroup []StepSpec
|
||
for i < len(wf.Steps) && wf.Steps[i].Parallel {
|
||
if wf.Steps[i].Status != "skipped" && !wf.Steps[i].Frozen {
|
||
parallelGroup = append(parallelGroup, wf.Steps[i])
|
||
}
|
||
i++
|
||
}
|
||
|
||
if len(parallelGroup) > 0 {
|
||
// 治理:并行组逐步预扣预算,超限即硬停(partial output 已由归档机制保留)
|
||
for range parallelGroup {
|
||
if rErr := ledger.ReserveStep(); rErr != nil {
|
||
_ = ledger.Save(wsDir)
|
||
if st := stream.FromContext(ctx); st != nil {
|
||
st.EmitPhase("governance", "预算硬停: "+rErr.Error(), "taskgov", 0, 0, 0)
|
||
}
|
||
wf.Status = "failed"
|
||
_ = d.SaveWorkflow(wsDir, wf)
|
||
_ = d.updateWorkspaceStatus(wsDir, "failed")
|
||
return &WorkflowExecutionResult{
|
||
Success: false,
|
||
ErrorReason: "预算硬停: " + rErr.Error(),
|
||
Duration: time.Since(start),
|
||
}, rErr
|
||
}
|
||
}
|
||
for _, pg := range parallelGroup {
|
||
markStepExecuting(wf, pg.ID)
|
||
}
|
||
var wg sync.WaitGroup
|
||
var groupErr error
|
||
for _, s := range parallelGroup {
|
||
sCopy := s
|
||
wg.Add(1)
|
||
go func() {
|
||
defer wg.Done()
|
||
if err := d.runOneStep(runCtx, wsDir, sCopy, backend, timeout, opts.StepNotify, &mu, &producedFiles, ledger); err != nil {
|
||
mu.Lock()
|
||
if groupErr == nil {
|
||
groupErr = err
|
||
}
|
||
mu.Unlock()
|
||
}
|
||
}()
|
||
}
|
||
wg.Wait()
|
||
if groupErr != nil {
|
||
wf.Status = "failed"
|
||
_ = d.SaveWorkflow(wsDir, wf)
|
||
_ = d.updateWorkspaceStatus(wsDir, "failed")
|
||
return &WorkflowExecutionResult{
|
||
Success: false,
|
||
ErrorReason: groupErr.Error(),
|
||
Duration: time.Since(start),
|
||
}, groupErr
|
||
}
|
||
stepsRun += len(parallelGroup)
|
||
// 治理:FSM 受控迁移 + 步骤归档锁定 + 软线提醒
|
||
for _, pg := range parallelGroup {
|
||
if tErr := TransitionStep(wf, pg.ID, StepStateCompleted); tErr == nil {
|
||
now := time.Now()
|
||
for idx := range wf.Steps {
|
||
if wf.Steps[idx].ID == pg.ID {
|
||
if _, fErr := FreezeStep(wsDir, pg); fErr == nil {
|
||
wf.Steps[idx].Frozen = true
|
||
wf.Steps[idx].ArchivedAt = &now
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if msg := ledger.CheckSoftLimit(); msg != "" {
|
||
if st := stream.FromContext(ctx); st != nil {
|
||
st.EmitPhase("governance", msg, "taskgov", 0, 0, 0)
|
||
}
|
||
}
|
||
_ = d.SaveWorkflow(wsDir, wf)
|
||
}
|
||
} else {
|
||
stepIndex := i
|
||
i++
|
||
// 治理:预算预扣(超限硬停)
|
||
if rErr := ledger.ReserveStep(); rErr != nil {
|
||
_ = ledger.Save(wsDir)
|
||
if st := stream.FromContext(ctx); st != nil {
|
||
st.EmitPhase("governance", "预算硬停: "+rErr.Error(), "taskgov", 0, 0, 0)
|
||
}
|
||
wf.Status = "failed"
|
||
_ = d.SaveWorkflow(wsDir, wf)
|
||
_ = d.updateWorkspaceStatus(wsDir, "failed")
|
||
return &WorkflowExecutionResult{
|
||
Success: false,
|
||
ErrorReason: "预算硬停: " + rErr.Error(),
|
||
Duration: time.Since(start),
|
||
}, rErr
|
||
}
|
||
// 治理:受控迁移至执行态(legacy 重跑场景直接放行)
|
||
markStepExecuting(wf, step.ID)
|
||
if err := d.runOneStep(runCtx, wsDir, step, backend, timeout, opts.StepNotify, &mu, &producedFiles, ledger); err != nil {
|
||
wf.Status = "failed"
|
||
_ = d.SaveWorkflow(wsDir, wf)
|
||
_ = d.updateWorkspaceStatus(wsDir, "failed")
|
||
return &WorkflowExecutionResult{
|
||
Success: false,
|
||
ErrorReason: err.Error(),
|
||
Duration: time.Since(start),
|
||
}, err
|
||
}
|
||
stepsRun++
|
||
// 治理:FSM 受控迁移 + 步骤归档锁定 + 软线提醒
|
||
if tErr := TransitionStep(wf, step.ID, StepStateCompleted); tErr == nil {
|
||
if _, fErr := FreezeStep(wsDir, step); fErr == nil {
|
||
now := time.Now()
|
||
wf.Steps[stepIndex].Frozen = true
|
||
wf.Steps[stepIndex].ArchivedAt = &now
|
||
}
|
||
}
|
||
if msg := ledger.CheckSoftLimit(); msg != "" {
|
||
if st := stream.FromContext(ctx); st != nil {
|
||
st.EmitPhase("governance", msg, "taskgov", 0, 0, 0)
|
||
}
|
||
}
|
||
_ = d.SaveWorkflow(wsDir, wf)
|
||
|
||
if step.AllowDynamicAdjustment && d.evaluator != nil {
|
||
d.applyDynamicAdjustment(ctx, wsDir, wf, step, &mu, opts.StepNotify)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 收集最终交付物并汇总到工作区根目录 output.md
|
||
finalContent, _ := d.resolveFinalDeliverable(wsDir, wf)
|
||
if len(finalContent) == 0 {
|
||
// 容错:若未提取到内容,读取 template.md
|
||
finalContent, _ = os.ReadFile(filepath.Join(wsDir, "template.md"))
|
||
}
|
||
rootOut := filepath.Join(wsDir, "output.md")
|
||
_ = os.WriteFile(rootOut, finalContent, 0o644)
|
||
|
||
wf.Status = "completed"
|
||
_ = d.SaveWorkflow(wsDir, wf)
|
||
_ = d.updateWorkspaceStatus(wsDir, "completed")
|
||
|
||
bb, _ := d.LoadBlackboard(wsDir)
|
||
var finalFields map[string]string
|
||
if bb != nil {
|
||
finalFields = bb.Fields
|
||
}
|
||
|
||
return &WorkflowExecutionResult{
|
||
Success: true,
|
||
StepsRun: stepsRun,
|
||
Deliverable: rootOut,
|
||
FinalContent: finalContent,
|
||
Duration: time.Since(start),
|
||
Fields: finalFields,
|
||
}, nil
|
||
}
|
||
|
||
// markStepExecuting 受控迁移至执行态;对 legacy 重跑场景(如 completed 未冻结的
|
||
// 历史工作区再次 Execute)迁移表不放行时直接落位并保留轨迹审计语义
|
||
func markStepExecuting(wf *WorkflowPlan, stepID string) {
|
||
if err := TransitionStep(wf, stepID, StepStateExecuting); err != nil {
|
||
for i := range wf.Steps {
|
||
if wf.Steps[i].ID == stepID {
|
||
wf.Steps[i].Status = StepStateExecuting
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
func (d *WorkflowDriver) runOneStep(
|
||
ctx context.Context,
|
||
wsDir string,
|
||
step StepSpec,
|
||
backend agent.Backend,
|
||
stepTimeout time.Duration,
|
||
notify func(StepEvent),
|
||
mu *sync.Mutex,
|
||
producedFiles *[]string,
|
||
ledger *BudgetLedger,
|
||
) error {
|
||
if mu == nil {
|
||
var localMu sync.Mutex
|
||
mu = &localMu
|
||
}
|
||
if producedFiles == nil {
|
||
var localFiles []string
|
||
producedFiles = &localFiles
|
||
}
|
||
|
||
// 治理:归档锁定守卫——冻结步骤禁止隐式重跑,必须显式 ReopenStep
|
||
if step.Frozen {
|
||
return fmt.Errorf("治理拦截: 步骤 %s 已归档锁定(完成即冻结),如需重跑请先显式重开", step.ID)
|
||
}
|
||
|
||
subDir := filepath.Join(wsDir, filepath.FromSlash(step.SubWorkspace))
|
||
_ = os.MkdirAll(subDir, 0o755)
|
||
|
||
deliverableName := "output.md"
|
||
if step.Mode == "plan" || step.Mode == "replan" {
|
||
deliverableName = "output_plan.md"
|
||
} else if step.Mode == "review_plan" {
|
||
deliverableName = "review_notes.md"
|
||
}
|
||
|
||
if notify != nil {
|
||
notify(StepEvent{
|
||
StepID: step.ID,
|
||
Role: step.Role,
|
||
Title: step.Title,
|
||
Mode: step.Mode,
|
||
Status: "started",
|
||
})
|
||
}
|
||
streamer := stream.FromContext(ctx)
|
||
roleTitle := FriendlyRoleName(step.Role)
|
||
if streamer != nil {
|
||
streamer.EmitPhase("step_executing", fmt.Sprintf("%s正在进行:%s...", roleTitle, step.Title), step.Role, 0, 0, 0)
|
||
}
|
||
stepStart := time.Now()
|
||
|
||
// 若当前步骤为 leader 审核定稿,且依赖前序专员稿件,先预置 baseline 草稿,供审查与润色
|
||
if step.Role == "leader" && deliverableName == "output.md" {
|
||
targetFile := filepath.Join(subDir, deliverableName)
|
||
if _, err := os.Stat(targetFile); os.IsNotExist(err) && len(step.Needs) > 0 {
|
||
srcFile := filepath.Join(wsDir, filepath.FromSlash(step.Needs[0]))
|
||
if srcData, err := os.ReadFile(srcFile); err == nil && len(srcData) > 0 {
|
||
_ = os.WriteFile(targetFile, srcData, 0o644)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 1. 读取 TASK.md 与 AGENTS.md
|
||
taskData, _ := os.ReadFile(filepath.Join(subDir, "TASK.md"))
|
||
agentsData, _ := os.ReadFile(filepath.Join(subDir, "AGENTS.md"))
|
||
|
||
targets := []string{deliverableName}
|
||
if step.Role == "organizer" {
|
||
targets = append(targets, "fields.json")
|
||
}
|
||
|
||
// 治理:执行前重复识别——若历史轨迹显示该步骤已连续两次无进度重复,强制终止
|
||
if mu != nil {
|
||
mu.Lock()
|
||
}
|
||
histEntries := LoadTrace(wsDir)
|
||
if mu != nil {
|
||
mu.Unlock()
|
||
}
|
||
if verdict := DetectRepetition(histEntries, step.ID); verdict.Detected {
|
||
if st := stream.FromContext(ctx); st != nil {
|
||
st.EmitPhase("governance", "重复识别强制终止: "+verdict.Reason, "taskgov", 0, 0, 0)
|
||
}
|
||
appendGovAudit(wsDir, "loop_aborted", map[string]string{"step_id": step.ID, "kind": verdict.Kind, "reason": verdict.Reason})
|
||
return fmt.Errorf("治理强制终止: %s", verdict.Reason)
|
||
}
|
||
|
||
// 治理:记录本轮执行前的既有产物(用于计算与上一轮的相似度)
|
||
prevOutPath := filepath.Join(subDir, deliverableName)
|
||
prevContent := ""
|
||
if b, rErr := os.ReadFile(prevOutPath); rErr == nil {
|
||
prevContent = string(b)
|
||
}
|
||
|
||
if stepTimeout <= 0 {
|
||
stepTimeout = 30 * time.Minute
|
||
}
|
||
req := agent.Request{
|
||
WorkDir: subDir,
|
||
Prompt: string(taskData),
|
||
Instructions: string(agentsData),
|
||
Targets: targets,
|
||
Timeout: stepTimeout,
|
||
}
|
||
|
||
res, err := backend.Execute(ctx, req)
|
||
duration := time.Since(stepStart).Milliseconds()
|
||
|
||
// 治理:轨迹入账(prompt/output 指纹 + 与上轮相似度)与预算入账
|
||
newContent := ""
|
||
if res != nil {
|
||
if b, ok := res.Patches[deliverableName]; ok {
|
||
newContent = string(b)
|
||
}
|
||
}
|
||
if newContent == "" {
|
||
if b, rErr := os.ReadFile(prevOutPath); rErr == nil {
|
||
newContent = string(b)
|
||
}
|
||
}
|
||
sim := OutputSimilarity(prevContent, newContent)
|
||
if mu != nil {
|
||
mu.Lock()
|
||
}
|
||
_ = AppendTrace(wsDir, TraceEntry{
|
||
Seq: len(histEntries) + 1,
|
||
StepID: step.ID,
|
||
Role: step.Role,
|
||
Timestamp: time.Now(),
|
||
PromptDigest: digestString(string(taskData)),
|
||
OutputDigest: digestString(newContent),
|
||
SimilarityToPrev: sim,
|
||
DurationMs: duration,
|
||
Success: err == nil && res != nil && res.Success,
|
||
})
|
||
if mu != nil {
|
||
mu.Unlock()
|
||
}
|
||
if ledger != nil {
|
||
if mu != nil {
|
||
mu.Lock()
|
||
}
|
||
ledger.RecordAgentCall()
|
||
if mu != nil {
|
||
mu.Unlock()
|
||
}
|
||
}
|
||
|
||
if err != nil {
|
||
if streamer != nil {
|
||
streamer.EmitPhase("step_failed", fmt.Sprintf("%s执行异常:%s", roleTitle, err.Error()), step.Role, 0, 0, 0)
|
||
}
|
||
if notify != nil {
|
||
notify(StepEvent{
|
||
StepID: step.ID,
|
||
Role: step.Role,
|
||
Title: step.Title,
|
||
Mode: step.Mode,
|
||
Status: "failed",
|
||
DurationMs: duration,
|
||
Error: err.Error(),
|
||
})
|
||
}
|
||
return fmt.Errorf("step %s (%s) backend execute: %w", step.ID, step.Role, err)
|
||
}
|
||
|
||
if res != nil && !res.Success {
|
||
errMsg := res.ErrorReason
|
||
if streamer != nil {
|
||
streamer.EmitPhase("step_failed", fmt.Sprintf("%s执行未成功:%s", roleTitle, errMsg), step.Role, 0, 0, 0)
|
||
}
|
||
if notify != nil {
|
||
notify(StepEvent{
|
||
StepID: step.ID,
|
||
Role: step.Role,
|
||
Title: step.Title,
|
||
Mode: step.Mode,
|
||
Status: "failed",
|
||
DurationMs: duration,
|
||
Error: errMsg,
|
||
})
|
||
}
|
||
return fmt.Errorf("step %s failed: %s", step.ID, errMsg)
|
||
}
|
||
|
||
// 2. 确认 deliverable 文件落盘
|
||
targetPath := filepath.Join(subDir, deliverableName)
|
||
if _, err := os.Stat(targetPath); err != nil {
|
||
// 若后端补丁在内存中返回,写入磁盘
|
||
if res != nil && len(res.Patches[deliverableName]) > 0 {
|
||
_ = os.WriteFile(targetPath, res.Patches[deliverableName], 0o644)
|
||
} else {
|
||
return fmt.Errorf("step %s missing target deliverable %s", step.ID, deliverableName)
|
||
}
|
||
}
|
||
|
||
// 3. 检查是否有 fields.json 并合并入 blackboard.json
|
||
fieldsPath := filepath.Join(subDir, "fields.json")
|
||
if fData, err := os.ReadFile(fieldsPath); err == nil {
|
||
var extractedFields map[string]string
|
||
if err := json.Unmarshal(fData, &extractedFields); err == nil {
|
||
mu.Lock()
|
||
if bb, err := d.LoadBlackboard(wsDir); err == nil {
|
||
if bb.Fields == nil {
|
||
bb.Fields = make(map[string]string)
|
||
}
|
||
for k, v := range extractedFields {
|
||
bb.Fields[k] = v
|
||
}
|
||
_ = d.SaveBlackboard(wsDir, bb)
|
||
}
|
||
mu.Unlock()
|
||
}
|
||
}
|
||
|
||
// 4. 记录日志
|
||
logEntry := fmt.Sprintf("[%s] 步骤 %s (%s) 执行完成,耗时 %dms\n",
|
||
time.Now().Format(time.RFC3339), step.ID, step.Role, duration)
|
||
_ = os.WriteFile(filepath.Join(subDir, "execution.log"), []byte(logEntry), 0o644)
|
||
|
||
mu.Lock()
|
||
*producedFiles = append(*producedFiles, filepath.ToSlash(filepath.Join(step.SubWorkspace, deliverableName)))
|
||
mu.Unlock()
|
||
|
||
if streamer != nil {
|
||
streamer.EmitPhase("step_completed", fmt.Sprintf("%s完成任务:%s (耗时 %dms)", roleTitle, step.Title, duration), step.Role, 0, 0, 0)
|
||
if stepData, err := os.ReadFile(targetPath); err == nil && len(stepData) > 0 {
|
||
streamer.EmitDetail("step_output", step.ID, step.Role, fmt.Sprintf("%s阶段性产出: %s", roleTitle, step.Title), string(stepData), true)
|
||
}
|
||
}
|
||
|
||
if notify != nil {
|
||
notify(StepEvent{
|
||
StepID: step.ID,
|
||
Role: step.Role,
|
||
Title: step.Title,
|
||
Mode: step.Mode,
|
||
Status: "completed",
|
||
DurationMs: duration,
|
||
})
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
func (d *WorkflowDriver) applyDynamicAdjustment(
|
||
ctx context.Context,
|
||
wsDir string,
|
||
wf *WorkflowPlan,
|
||
step StepSpec,
|
||
mu *sync.Mutex,
|
||
notify func(StepEvent),
|
||
) {
|
||
deliverableName := "output.md"
|
||
if step.Mode == "plan" || step.Mode == "replan" {
|
||
deliverableName = "output_plan.md"
|
||
}
|
||
outPath := filepath.Join(wsDir, filepath.FromSlash(step.SubWorkspace), deliverableName)
|
||
outContent, _ := os.ReadFile(outPath)
|
||
bb, _ := d.LoadBlackboard(wsDir)
|
||
|
||
evalRes, err := d.evaluator.EvaluateStepOutput(wsDir, step, string(outContent), bb)
|
||
if err != nil || evalRes == nil {
|
||
return
|
||
}
|
||
|
||
mu.Lock()
|
||
defer mu.Unlock()
|
||
|
||
// 1. 合并新推断的基准假设
|
||
if len(evalRes.NewAssumptions) > 0 && bb != nil {
|
||
for _, na := range evalRes.NewAssumptions {
|
||
exists := false
|
||
for _, ea := range bb.Assumptions {
|
||
if ea.Topic == na.Topic {
|
||
exists = true
|
||
break
|
||
}
|
||
}
|
||
if !exists {
|
||
bb.Assumptions = append(bb.Assumptions, na)
|
||
}
|
||
}
|
||
_ = d.SaveBlackboard(wsDir, bb)
|
||
}
|
||
|
||
// 2. 处理动态跳步
|
||
if len(evalRes.StepsToSkip) > 0 {
|
||
for _, skipID := range evalRes.StepsToSkip {
|
||
target := strings.ToLower(skipID)
|
||
for idx := range wf.Steps {
|
||
curID := strings.ToLower(wf.Steps[idx].ID)
|
||
curRole := strings.ToLower(wf.Steps[idx].Role)
|
||
isMatch := (curID == target || curRole == target || strings.Contains(curID, target) || strings.Contains(target, curID))
|
||
if isMatch && (wf.Steps[idx].Status == "pending" || wf.Steps[idx].Status == "") {
|
||
wf.Steps[idx].Status = "skipped"
|
||
if notify != nil {
|
||
notify(StepEvent{
|
||
StepID: wf.Steps[idx].ID,
|
||
Role: wf.Steps[idx].Role,
|
||
Title: wf.Steps[idx].Title,
|
||
Status: "skipped",
|
||
})
|
||
}
|
||
}
|
||
}
|
||
}
|
||
_ = d.SaveWorkflow(wsDir, wf)
|
||
}
|
||
|
||
// 3. 处理动态加步
|
||
if len(evalRes.DynamicStepsToAdd) > 0 {
|
||
squadCfg, _ := d.loadSquadConfig(wsDir)
|
||
tmpl, _ := os.ReadFile(filepath.Join(wsDir, "template.md"))
|
||
|
||
for _, addStep := range evalRes.DynamicStepsToAdd {
|
||
alreadyExists := false
|
||
for _, s := range wf.Steps {
|
||
if s.ID == addStep.ID {
|
||
alreadyExists = true
|
||
break
|
||
}
|
||
}
|
||
if alreadyExists {
|
||
continue
|
||
}
|
||
|
||
if addStep.SubWorkspace == "" {
|
||
addStep.SubWorkspace = fmt.Sprintf("tasks/%02d_%s", len(wf.Steps)+1, addStep.Role)
|
||
}
|
||
if addStep.Status == "" {
|
||
addStep.Status = "pending"
|
||
}
|
||
if addStep.Mode == "" {
|
||
addStep.Mode = "execute"
|
||
}
|
||
|
||
// 构建子沙箱
|
||
_ = d.engine.buildSubWorkspaces(wsDir, WorkflowPlan{Steps: []StepSpec{addStep}}, squadCfg, string(tmpl))
|
||
|
||
// 插入在 writer 或 leader 之前,确保专家成果在成文前产生
|
||
insertIdx := len(wf.Steps)
|
||
for idx, s := range wf.Steps {
|
||
if s.Role == "writer" || s.Role == "leader" {
|
||
insertIdx = idx
|
||
break
|
||
}
|
||
}
|
||
|
||
newSteps := make([]StepSpec, 0, len(wf.Steps)+1)
|
||
newSteps = append(newSteps, wf.Steps[:insertIdx]...)
|
||
newSteps = append(newSteps, addStep)
|
||
newSteps = append(newSteps, wf.Steps[insertIdx:]...)
|
||
wf.Steps = newSteps
|
||
|
||
streamer := stream.FromContext(ctx)
|
||
if streamer != nil {
|
||
streamer.EmitPhase("dynamic_adjustment", fmt.Sprintf("单步评估发现缺口,动态追加任务步骤:%s (%s)", addStep.Title, FriendlyRoleName(addStep.Role)), addStep.Role, 0, 0, 0)
|
||
}
|
||
}
|
||
_ = d.SaveWorkflow(wsDir, wf)
|
||
}
|
||
}
|
||
|
||
func (d *WorkflowDriver) resolveFinalDeliverable(wsDir string, wf *WorkflowPlan) ([]byte, string) {
|
||
// 查找优先级:leader 的 output.md > writer 的 output.md > 最后一个步骤的 output.md
|
||
for i := len(wf.Steps) - 1; i >= 0; i-- {
|
||
step := wf.Steps[i]
|
||
if step.Role == "leader" && (step.Mode == "execute" || step.Mode == "") {
|
||
p := filepath.Join(wsDir, filepath.FromSlash(step.SubWorkspace), "output.md")
|
||
if data, err := os.ReadFile(p); err == nil && len(strings.TrimSpace(string(data))) > 0 {
|
||
return data, p
|
||
}
|
||
}
|
||
}
|
||
|
||
for i := len(wf.Steps) - 1; i >= 0; i-- {
|
||
step := wf.Steps[i]
|
||
if step.Role == "writer" && (step.Mode == "execute" || step.Mode == "") {
|
||
p := filepath.Join(wsDir, filepath.FromSlash(step.SubWorkspace), "output.md")
|
||
if data, err := os.ReadFile(p); err == nil && len(strings.TrimSpace(string(data))) > 0 {
|
||
return data, p
|
||
}
|
||
}
|
||
}
|
||
|
||
for i := len(wf.Steps) - 1; i >= 0; i-- {
|
||
step := wf.Steps[i]
|
||
p := filepath.Join(wsDir, filepath.FromSlash(step.SubWorkspace), "output.md")
|
||
if data, err := os.ReadFile(p); err == nil && len(strings.TrimSpace(string(data))) > 0 {
|
||
return data, p
|
||
}
|
||
}
|
||
|
||
return nil, ""
|
||
}
|
||
|
||
func (d *WorkflowDriver) loadSquadConfig(wsDir string) (SquadConfig, error) {
|
||
p := filepath.Join(wsDir, "squad.json")
|
||
var sc SquadConfig
|
||
data, err := os.ReadFile(p)
|
||
if err != nil {
|
||
return sc, err
|
||
}
|
||
err = json.Unmarshal(data, &sc)
|
||
return sc, err
|
||
}
|
||
|
||
func (d *WorkflowDriver) updateWorkspaceStatus(wsDir string, status string) error {
|
||
p := filepath.Join(wsDir, "workspace.json")
|
||
data, err := os.ReadFile(p)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
var meta WorkspaceMetadata
|
||
if err := json.Unmarshal(data, &meta); err != nil {
|
||
return err
|
||
}
|
||
meta.Status = status
|
||
meta.UpdatedAt = time.Now()
|
||
if status == "completed" {
|
||
now := time.Now()
|
||
meta.CompletedAt = &now
|
||
}
|
||
return writeJSON(p, meta)
|
||
}
|