feat(workflow): implement dynamic workflow driver with sub-workspace isolation

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
WeiCHEN
2026-09-07 18:23:42 +08:00
co-authored by Cursor
parent d0c6559dd6
commit 0f17f74cc2
+481
View File
@@ -0,0 +1,481 @@
package squad
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"time"
"chat-agent-system/internal/agent"
)
// 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)
}
// 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
}
func NewWorkflowDriver(engine *WorkspaceEngine) *WorkflowDriver {
if engine == nil {
engine = NewWorkspaceEngine()
}
return &WorkflowDriver{engine: engine}
}
// 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
i := 0
for i < len(wf.Steps) {
step := wf.Steps[i]
if step.Parallel {
var parallelGroup []StepSpec
for i < len(wf.Steps) && wf.Steps[i].Parallel {
parallelGroup = append(parallelGroup, wf.Steps[i])
i++
}
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, opts.StepNotify, &mu, &producedFiles); 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)
} else {
i++
if err := d.runOneStep(runCtx, wsDir, step, backend, opts.StepNotify, &mu, &producedFiles); 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++
}
}
// 收集最终交付物并汇总到工作区根目录 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
}
func (d *WorkflowDriver) runOneStep(
ctx context.Context,
wsDir string,
step StepSpec,
backend agent.Backend,
notify func(StepEvent),
mu *sync.Mutex,
producedFiles *[]string,
) error {
if mu == nil {
var localMu sync.Mutex
mu = &localMu
}
if producedFiles == nil {
var localFiles []string
producedFiles = &localFiles
}
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",
})
}
stepStart := time.Now()
// 1. 读取 TASK.md 与 AGENTS.md
taskData, _ := os.ReadFile(filepath.Join(subDir, "TASK.md"))
agentsData, _ := os.ReadFile(filepath.Join(subDir, "AGENTS.md"))
req := agent.Request{
WorkDir: subDir,
Prompt: string(taskData),
Instructions: string(agentsData),
Targets: []string{deliverableName},
Timeout: 30 * time.Minute,
}
res, err := backend.Execute(ctx, req)
duration := time.Since(stepStart).Milliseconds()
if err != nil {
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 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 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) 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)
}