Files
Agent-System/internal/squad/workflow.go
T
agent-bot 2c75dcaf69
CI / go-vet (pull_request) Successful in 13s
CI / short-tests-report (pull_request) Failing after 35s
CI / backup-restore-drill (pull_request) Successful in 36s
CI / web (pull_request) Successful in 32s
refactor(squad): AssetProvider port replaces direct template dependency
KTD1 fifth step (behavior preserved):
- squad.AssetProvider{DocTemplate,RenderDoc,AgentInstruction,
  AgentInstructionFile} injected via Options.Assets into Service,
  WorkspaceEngine and AdaptivePlanner
- bizdev templateAssetProvider maps port to internal/template (assembly side)
- nil-provider paths degrade to the same fallbacks the old error branches
  used (placeholder template, role header) — fail-visible, no guessing
- squad -> template import eliminated; arch edge moved bizdev->template
- verified: build/vet/arch green, squad+bizdev short tests pass
2026-09-29 06:29:51 +00:00

1056 lines
31 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 (
"bytes"
"context"
"encoding/json"
"fmt"
"log/slog"
"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)
// 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"`
// DegradedSteps A5:非关键步骤失败降级后继续的步骤 ID(Success 仍为 true)。
DegradedSteps []string `json:"degraded_steps,omitempty"`
}
// WorkflowDriver 负责动态工作流调度与沙箱隔离执行
type WorkflowDriver struct {
engine *WorkspaceEngine
evaluator *StepEvaluator
}
func NewWorkflowDriver(engine *WorkspaceEngine) *WorkflowDriver {
if engine == nil {
engine = NewWorkspaceEngine(nil)
}
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 := ProgressFromContext(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()
defer func() {
if rec := recover(); rec != nil {
mu.Lock()
if groupErr == nil {
groupErr = fmt.Errorf("parallel step panic: %v", rec)
}
mu.Unlock()
}
}()
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 {
if cerr := contextErrString(runCtx); cerr != "" {
slog.Warn("squad_parallel_cancelled", "error", groupErr.Error(), "cancel_reason", cerr, "parent_cancel", contextErrString(ctx))
}
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 := ProgressFromContext(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 := ProgressFromContext(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 {
// A5 非关键步骤降级:记录 degraded 继续推进,收口统一标 degraded
if !step.IsCritical() {
markStepDegraded(wsDir, wf, step, err)
stepsRun++
_ = d.SaveWorkflow(wsDir, wf)
continue
}
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 := ProgressFromContext(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)
// 收口:A5 存在降级步骤时整体状态 degraded(非 completed 也非 failed)
finalStatus := "completed"
var degradedSteps []string
for _, s := range wf.Steps {
if s.Status == "degraded" {
finalStatus = "degraded"
degradedSteps = append(degradedSteps, s.ID)
}
}
wf.Status = finalStatus
_ = d.SaveWorkflow(wsDir, wf)
_ = d.updateWorkspaceStatus(wsDir, finalStatus)
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,
DegradedSteps: degradedSteps,
}, 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
}
}
}
}
// markStepDegraded A5:非关键步骤失败降级——状态落 degraded、记录错误、轨迹审计。
func markStepDegraded(wsDir string, wf *WorkflowPlan, step StepSpec, err error) []string {
for i := range wf.Steps {
if wf.Steps[i].ID == step.ID {
wf.Steps[i].Status = "degraded"
wf.Steps[i].Error = err.Error()
}
}
appendGovAudit(wsDir, "step_degraded", map[string]string{
"step_id": step.ID,
"error": err.Error(),
"kind": "non_critical",
})
return nil
}
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",
})
}
sink := ProgressFromContext(ctx)
roleTitle := FriendlyRoleName(step.Role)
if sink != nil {
sink.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}
// fields.json is requested in TASK.md for organizer, but must not be a hard
// runner target: models often emit only output.md when materials are thin,
// and that used to fail closed as MISSING_TARGET / kill FullNone nightly.
// 治理:执行前重复识别——若历史轨迹显示该步骤已连续两次无进度重复,强制终止
if mu != nil {
mu.Lock()
}
histEntries := LoadTrace(wsDir)
if mu != nil {
mu.Unlock()
}
if verdict := DetectRepetition(histEntries, step.ID); verdict.Detected {
if st := ProgressFromContext(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,
}
// 过程流桥:把成员执行的思考/工具调用增量翻译为 detail 事件(sink 缺失时为 no-op)。
// 注意 retry 循环内多次 Execute 共用一个 bridge 语义(同 step 聚合),Stop 只在首次调用。
stopProcessBridge := AttachProcessBridge(sink, step, &req)
var (
res *agent.Result
execErr error
matErr error
duration int64
ok bool
lastAssess agent.FailureAssessment
basePrompt = string(taskData)
)
defer stopProcessBridge()
for attempt := 1; attempt <= agent.MaxTransientStepInvocations; attempt++ {
onDisk, _ := os.ReadFile(prevOutPath)
wroteThisInvocation := deliverableWroteThisInvocation(prevContent, onDisk)
if attempt > 1 && wroteThisInvocation {
// Preserve a this-invocation file (may be truncated). Do not call again.
break
}
req.Prompt = basePrompt
if attempt > 1 && strings.Contains(lastAssess.Reason, "settled-no-deliverable") {
req.Prompt = basePrompt + "\n\n【纠偏重试】你上一轮没有调用 write 工具写 " + deliverableName +
",本轮必须先用 write 工具把完整交付物写入当前目录 " + deliverableName + ",再结束。"
}
if attempt > 1 && sink != nil {
sink.EmitPhase(
"step_retrying",
fmt.Sprintf("%s遇瞬时失败,发起全新步骤调用(%d/%d)", roleTitle, attempt, agent.MaxTransientStepInvocations),
step.Role, 0, 0, 0,
)
}
res, execErr = backend.Execute(ctx, req)
duration = time.Since(stepStart).Milliseconds()
matErr = nil
incoming := map[string][]byte{}
if res != nil {
incoming = res.Patches
}
hasTrustedPatch := incomingHasDeliverable(subDir, deliverableName, incoming)
if execErr == nil && res != nil && res.Success {
if _, matErr = agent.MaterializeTargets(subDir, []string{deliverableName}, incoming); matErr != nil {
res.Success = false
res.ErrorReason = matErr.Error()
} else if !hasTrustedPatch {
after, _ := os.ReadFile(prevOutPath)
if !deliverableWroteThisInvocation(prevContent, after) {
matErr = fmt.Errorf("missing target deliverable %s", deliverableName)
res.Success = false
res.ErrorReason = matErr.Error()
}
}
}
onDisk, _ = os.ReadFile(prevOutPath)
wroteThisInvocation = deliverableWroteThisInvocation(prevContent, onDisk)
newContent := ""
if hasTrustedPatch && res != nil {
if b, has := res.Patches[deliverableName]; has {
newContent = string(b)
}
}
if newContent == "" && wroteThisInvocation {
newContent = string(onDisk)
}
recoveredFromDisk := false
if strings.TrimSpace(newContent) == "" {
if again, rerr := os.ReadFile(prevOutPath); rerr == nil && deliverableWroteThisInvocation(prevContent, again) {
newContent = string(again)
wroteThisInvocation = true
recoveredFromDisk = true
}
}
ok = stepOutputAccepted(execErr, matErr, wroteThisInvocation, newContent, res)
if !ok || recoveredFromDisk {
slog.Info("squad_step_output_judge",
"step_id", step.ID,
"exec_err", errString(execErr),
"res_success", res != nil && res.Success,
"res_error_reason", resultErrorReason(res),
"has_trusted_patch", hasTrustedPatch,
"wrote_this_invocation", wroteThisInvocation,
"mat_err", errString(matErr),
"recovered_from_disk", recoveredFromDisk,
"accepted", ok,
)
}
sim := OutputSimilarity(prevContent, newContent)
if mu != nil {
mu.Lock()
}
traceSeq := len(LoadTrace(wsDir)) + 1
_ = AppendTrace(wsDir, TraceEntry{
Seq: traceSeq,
StepID: step.ID,
Role: step.Role,
Timestamp: time.Now(),
PromptDigest: digestString(string(taskData)),
OutputDigest: digestString(newContent),
SimilarityToPrev: sim,
DurationMs: duration,
Success: ok,
ExecErr: errString(execErr),
ResultSuccess: res != nil && res.Success,
ResultError: resultErrorReason(res),
HasTrustedPatch: hasTrustedPatch,
WroteThisInvocation: wroteThisInvocation,
MatErr: errString(matErr),
RecoveredFromDisk: strings.TrimSpace(newContent) != "" && !(res != nil && res.Success),
CancelReason: contextErrString(ctx),
})
if mu != nil {
mu.Unlock()
}
if ledger != nil {
if mu != nil {
mu.Lock()
}
ledger.RecordAgentCall()
if mu != nil {
mu.Unlock()
}
}
if ok {
break
}
if ctx.Err() != nil {
if execErr == nil {
execErr = ctx.Err()
}
break
}
lastAssess = agent.AssessStepFailure(agent.StepFailureSignals{
Err: execErr,
Result: res,
WorkDir: subDir,
TargetMissing: !wroteThisInvocation && (matErr != nil || (res != nil && strings.Contains(res.ErrorReason, "missing target deliverable"))),
TargetExists: wroteThisInvocation,
})
if lastAssess.Class != agent.FailureTransient || attempt >= agent.MaxTransientStepInvocations {
break
}
}
if !ok && execErr != nil {
if sink != nil {
sink.EmitPhase("step_failed", fmt.Sprintf("%s执行异常:%s", roleTitle, execErr.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: execErr.Error(),
})
}
return fmt.Errorf("step %s (%s) backend execute: %w", step.ID, step.Role, execErr)
}
if !ok && res != nil && !res.Success {
errMsg := res.ErrorReason
if sink != nil {
sink.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)
}
if !ok {
reason := "missing target deliverable " + deliverableName
if res != nil && res.ErrorReason != "" {
reason = res.ErrorReason
}
if matErr != nil {
reason = matErr.Error()
}
return fmt.Errorf("step %s %s", step.ID, reason)
}
targetPath := filepath.Join(subDir, 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 sink != nil {
sink.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 {
sink.EmitDetail(DetailEvent{
Category: "step_output",
StepID: step.ID,
Role: step.Role,
Title: fmt.Sprintf("%s阶段性产出: %s", roleTitle, step.Title),
Content: string(stepData),
Collapsible: true,
})
}
}
if notify != nil {
notify(StepEvent{
StepID: step.ID,
Role: step.Role,
Title: step.Title,
Mode: step.Mode,
Status: "completed",
DurationMs: duration,
})
}
return nil
}
// stepOutputAccepted 以本轮写盘产物为事实源。runner 把 Success 标成 false
// (例如 typed result 没带回 patch)但文件已经写好时,不让这一处否决步骤。
func stepOutputAccepted(execErr, matErr error, wrote bool, newContent string, res *agent.Result) bool {
if execErr != nil || matErr != nil {
return false
}
if res != nil && res.Success {
return true
}
if !wrote || strings.TrimSpace(newContent) == "" {
return false
}
// 连接中断或可重试失败留下的文件可能是截断的,不能当成交付成功。
// 交付物缺失/空结果但盘上已有正文时,以文件为准。
if res != nil && (res.Retryable || hardRunnerFailure(res.ErrorReason)) {
return false
}
return true
}
func hardRunnerFailure(reason string) bool {
reason = strings.ToLower(strings.TrimSpace(reason))
if reason == "" || strings.Contains(reason, "missing target deliverable") {
return false
}
return true
}
func errString(err error) string {
if err == nil {
return ""
}
return err.Error()
}
func resultErrorReason(res *agent.Result) string {
if res == nil {
return ""
}
return res.ErrorReason
}
func contextErrString(ctx context.Context) string {
if ctx == nil || ctx.Err() == nil {
return ""
}
return ctx.Err().Error()
}
func deliverableWroteThisInvocation(baseline string, onDisk []byte) bool {
if len(bytes.TrimSpace(onDisk)) == 0 {
return false
}
return string(onDisk) != baseline
}
func incomingHasDeliverable(workDir, target string, incoming map[string][]byte) bool {
for k, v := range incoming {
if len(bytes.TrimSpace(v)) == 0 {
continue
}
if agent.MatchDeliverableRel(workDir, k, target) {
return true
}
}
return false
}
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
sink := ProgressFromContext(ctx)
if sink != nil {
sink.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)
}