Why: A parallel group only checked the first step, free text cancelled confirmation and permission holds, and a failed reply deleted the pending request before the decision ran. Evidence: GOWORK=off go vet ./...; go test -count=1 ./squad/ ./cli/; go test -race -count=1 ./squad/. New tests cover the second parallel member, free-text holds, restored pending, AtomicWrite, and listenGuard. Impact: Any member that needs approval or bash permission keeps the whole group suspended. Free text on confirmation, permission, and quality review is stored as steering and the same request stays pending. A dispatch error restores pending.json. Refs: feature/agent-stack-r0-r1 Co-authored-by: Cursor <cursoragent@cursor.com>
833 lines
28 KiB
Go
833 lines
28 KiB
Go
package squad
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"fmt"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"agent-native/exec"
|
||
|
||
"error-code-system/errcode"
|
||
)
|
||
|
||
// flowDriver 工作流驱动器:推进 runState 直到终态或 HITL 挂起边界。
|
||
type flowDriver struct {
|
||
opts engineDeps
|
||
}
|
||
|
||
// advance 执行到边界;返回 (结果, 挂起上下文),二者互斥非 nil(infra 错误时都为 nil 且 err 非 nil)。
|
||
func (d *flowDriver) advance(ctx context.Context, st *runState) (res *RunResult, gate *hitlGate, err error) {
|
||
wf := st.Workflow()
|
||
wf.Status = "in_progress"
|
||
st.setWorkflow(wf)
|
||
d.emit(st, Event{Type: EvPhase, Text: "开始执行工作流"})
|
||
|
||
i := 0
|
||
for i < len(st.Plan.Workflow.Steps) {
|
||
step := st.Plan.Workflow.Steps[i]
|
||
switch step.Status {
|
||
case "completed", "skipped", "failed":
|
||
i++
|
||
continue
|
||
}
|
||
|
||
if out, g, err := d.gateBeforeRun(ctx, st, i); out != nil || g != nil || err != nil {
|
||
if out == nil && g != nil {
|
||
out = heldResult(st, g)
|
||
}
|
||
return out, g, err
|
||
}
|
||
|
||
if step.Parallel {
|
||
// 收集相邻并行组。组内每一步都先过审批和权限,任一需要挂起则整组不执行。
|
||
var group []int
|
||
for i < len(st.Plan.Workflow.Steps) {
|
||
s := st.Plan.Workflow.Steps[i]
|
||
if s.Parallel && s.Status != "completed" && s.Status != "skipped" && s.Status != "failed" {
|
||
if out, g, err := d.gateBeforeRun(ctx, st, i); out != nil || g != nil || err != nil {
|
||
if out == nil && g != nil {
|
||
out = heldResult(st, g)
|
||
}
|
||
return out, g, err
|
||
}
|
||
group = append(group, i)
|
||
i++
|
||
continue
|
||
}
|
||
break
|
||
}
|
||
if out, g, err := d.runParallel(ctx, st, group); out != nil || g != nil || err != nil {
|
||
if out == nil && g != nil {
|
||
out = heldResult(st, g)
|
||
}
|
||
return out, g, err
|
||
}
|
||
if g2 := d.checkBlockingGaps(st); g2 != nil {
|
||
return heldResult(st, g2), g2, nil
|
||
}
|
||
continue
|
||
}
|
||
|
||
out, g, err := d.runOneStep(ctx, st, i)
|
||
if out != nil || g != nil || err != nil {
|
||
if out == nil && g != nil {
|
||
out = heldResult(st, g)
|
||
}
|
||
return out, g, err
|
||
}
|
||
i++
|
||
|
||
// 阻塞缺口挂起。
|
||
if g2 := d.checkBlockingGaps(st); g2 != nil {
|
||
return heldResult(st, g2), g2, nil
|
||
}
|
||
|
||
// 动态调整(仅顺序步骤后,避免并发竞态)。
|
||
if st.Plan.Workflow.Steps[i-1].AllowDynamicAdjustment && d.opts.Evaluator != nil {
|
||
out, g, err := d.applyDynamicAdjustment(ctx, st, i-1)
|
||
if out != nil || g != nil || err != nil {
|
||
if out == nil && g != nil {
|
||
out = heldResult(st, g)
|
||
}
|
||
return out, g, err
|
||
}
|
||
}
|
||
}
|
||
|
||
return d.finalize(ctx, st)
|
||
}
|
||
|
||
// gateBeforeRun 在步骤真正执行前检查权限升级和显式审批。
|
||
// 返回非空结果或挂起时,调用方不得执行该步骤,也不得执行它所在的并行组。
|
||
func (d *flowDriver) gateBeforeRun(ctx context.Context, st *runState, i int) (*RunResult, *hitlGate, error) {
|
||
step := st.Plan.Workflow.Steps[i]
|
||
role, _ := findAgent(st.Plan.Squad.Agents, step.Role)
|
||
if role.BashPermission == BashPermissionAsk && !st.Approved["role:"+role.Role] {
|
||
switch d.policyRole(st, role).mode() {
|
||
case HitlDisabled, HitlAuto:
|
||
step.Error = "permission denied: role " + role.Role + " requires bash approval (hitl mode=" + string(d.policyRole(st, role).mode()) + ")"
|
||
st.Plan.Workflow.Steps[i] = step
|
||
return d.failStep(ctx, st, i)
|
||
default:
|
||
return d.hold(st, HitlPermission, step.ID, step.Role,
|
||
fmt.Sprintf("角色 %s(%s)声明 bash_permission=ask,是否授权其在本次运行中使用更高权限?", role.Name, role.Role),
|
||
[]HumanOption{
|
||
{Value: "approve", Label: "授权本次运行"},
|
||
{Value: "reject", Label: "拒绝,步骤失败"},
|
||
{Value: "abort", Label: "中止运行"},
|
||
},
|
||
fmt.Sprintf("步骤:%s\n角色:%s\n禁忌:%v", step.Title, role.Role, role.ForbiddenActions), "", nil)
|
||
}
|
||
}
|
||
if step.RequiresApproval && !st.Approved["step:"+step.ID] {
|
||
switch d.policyRole(st, role).mode() {
|
||
case HitlDisabled, HitlAuto:
|
||
step.Error = "approval required but hitl mode is " + string(d.policy(st).mode())
|
||
st.Plan.Workflow.Steps[i] = step
|
||
return d.failStep(ctx, st, i)
|
||
default:
|
||
return d.hold(st, HitlConfirmation, step.ID, step.Role,
|
||
fmt.Sprintf("步骤「%s」标记为需人工审批后才能执行,是否批准?", step.Title),
|
||
[]HumanOption{
|
||
{Value: "approve", Label: "批准执行"},
|
||
{Value: "skip", Label: "跳过该步骤"},
|
||
{Value: "abort", Label: "中止运行"},
|
||
},
|
||
fmt.Sprintf("角色:%s\n产出:%v", step.Role, step.Targets), "step_approval", nil)
|
||
}
|
||
}
|
||
return nil, nil, nil
|
||
}
|
||
|
||
// runParallel 并发执行一个并行组;关键步骤失败即终止整个运行。
|
||
func (d *flowDriver) runParallel(ctx context.Context, st *runState, idxs []int) (*RunResult, *hitlGate, error) {
|
||
var wg sync.WaitGroup
|
||
var mu sync.Mutex
|
||
failedCritical := -1
|
||
for _, idx := range idxs {
|
||
wg.Add(1)
|
||
go func(idx int) {
|
||
defer wg.Done()
|
||
out, _, err := d.runOneStep(ctx, st, idx)
|
||
mu.Lock()
|
||
defer mu.Unlock()
|
||
if (out != nil && out.Status == RunFailed) || err != nil {
|
||
if st.Plan.Workflow.Steps[idx].IsCritical() && failedCritical < 0 {
|
||
failedCritical = idx
|
||
}
|
||
}
|
||
}(idx)
|
||
}
|
||
wg.Wait()
|
||
if failedCritical >= 0 {
|
||
return d.failStep(ctx, st, failedCritical)
|
||
}
|
||
return nil, nil, nil
|
||
}
|
||
|
||
// runOneStep 执行单个步骤(含瞬态重试与交付物校验)。
|
||
func (d *flowDriver) runOneStep(ctx context.Context, st *runState, idx int) (*RunResult, *hitlGate, error) {
|
||
step := st.Plan.Workflow.Steps[idx]
|
||
role, ok := findAgent(st.Plan.Squad.Agents, step.Role)
|
||
if !ok {
|
||
if r, exists := d.opts.Registry.Get(step.Role); exists {
|
||
role = r
|
||
st.mu.Lock()
|
||
st.Plan.Squad.Agents = append(st.Plan.Squad.Agents, r)
|
||
st.mu.Unlock()
|
||
} else {
|
||
step.Status, step.Error = "failed", "role not found: "+step.Role
|
||
st.setStep(idx, step)
|
||
return d.failStep(ctx, st, idx)
|
||
}
|
||
}
|
||
subDir := filepath.Join(st.Meta.WorkDir, filepath.FromSlash(orDefault(step.SubWorkspace, fmt.Sprintf("tasks/%02d_%s", idx+1, step.Role))))
|
||
if err := os.MkdirAll(subDir, 0o755); err != nil {
|
||
return nil, nil, err
|
||
}
|
||
if step.SubWorkspace == "" {
|
||
step.SubWorkspace = filepath.ToSlash(filepath.Join("tasks", fmt.Sprintf("%02d_%s", idx+1, step.Role)))
|
||
}
|
||
|
||
d.emit(st, Event{Type: EvStepStarted, StepID: step.ID, Role: step.Role, Text: step.Title})
|
||
step.Status = "running"
|
||
step.Attempts++
|
||
step.Error = ""
|
||
// 与收口落盘同一把锁:并行步骤一边改 Steps、一边全量 marshal 会数据竞争。
|
||
st.mu.Lock()
|
||
st.Plan.Workflow.Steps[idx] = step
|
||
saveErr := st.save()
|
||
st.mu.Unlock()
|
||
if saveErr != nil {
|
||
return nil, nil, saveErr
|
||
}
|
||
started := time.Now()
|
||
|
||
prompt := readFileOr(filepath.Join(subDir, "TASK.md"), step.Title)
|
||
prompt = st.injectSteering(prompt)
|
||
instructions := readFileOr(filepath.Join(subDir, "AGENTS.md"), d.opts.Workspaces.ResolveInstructions(role))
|
||
|
||
var backend exec.Backend
|
||
backendName := ""
|
||
if step.IntentID != "" {
|
||
backendName = "intent:" + step.IntentID
|
||
} else {
|
||
var rerr error
|
||
backend, backendName, rerr = d.opts.Backends.Resolve(role, st.Meta.Backend)
|
||
if rerr != nil {
|
||
step.Status, step.Error = "failed", rerr.Error()
|
||
st.setStep(idx, step)
|
||
return d.failStep(ctx, st, idx)
|
||
}
|
||
}
|
||
|
||
targets := step.Targets
|
||
if len(targets) == 0 {
|
||
targets = []string{"output.md"}
|
||
}
|
||
// 步骤级共享预算:重试与首次尝试共用同一截止时刻
|
||
//(agent-execution 标准:重试层共享总预算,不随次数翻倍)。
|
||
var stepBudget *exec.Budget
|
||
if d.opts.StepTimeout > 0 {
|
||
stepBudget = &exec.Budget{Deadline: time.Now().Add(d.opts.StepTimeout)}
|
||
}
|
||
var result *exec.Result
|
||
var err error
|
||
for attempt := 1; attempt <= exec.MaxTransientStepInvocations; attempt++ {
|
||
reqPrompt := prompt
|
||
if attempt > 1 {
|
||
prevReason := "交付物缺失"
|
||
if err != nil {
|
||
prevReason = err.Error()
|
||
} else if result != nil && result.ErrorReason != "" {
|
||
prevReason = result.ErrorReason
|
||
}
|
||
reqPrompt += "\n\n【重试提示】上一次执行未成功(" + prevReason + ")。请务必把结果写入:" + strings.Join(targets, ", ")
|
||
}
|
||
req := exec.Request{
|
||
WorkDir: subDir,
|
||
Prompt: reqPrompt,
|
||
Instructions: instructions,
|
||
Targets: targets,
|
||
Timeout: d.opts.StepTimeout,
|
||
ToolProfile: orDefault(role.ToolProfile, exec.ToolProfileWorkspace),
|
||
OnEvent: func(ev exec.ProcessEvent) {
|
||
d.emit(st, Event{Type: EvDetail, StepID: step.ID, Role: step.Role, Text: ev.Text,
|
||
Data: marshalJSON(map[string]string{"process_type": ev.Type, "tool": ev.Tool, "detail": ev.Detail})})
|
||
},
|
||
// RoleSpec v2 贯通:工具白名单 / 技能 / 标识(原为死字段或缺失)。
|
||
ToolAllowlist: role.Tools,
|
||
Skills: firstNonEmptySlice(step.Skills, role.Skills),
|
||
SessionScope: st.Meta.ID + "/" + step.ID,
|
||
ExecutionID: st.Meta.ID + "-" + step.ID,
|
||
Budget: stepBudget,
|
||
}
|
||
if step.IntentID != "" {
|
||
// 系统功能步:不经智能体,由 IntentRunner 直接执行;缺席即 fail-closed。
|
||
// Runner 只产出内容(patches),由引擎统一物化到子工作区。
|
||
if d.opts.IntentRunner == nil {
|
||
step.Status, step.Error = "failed", fmt.Sprintf("intent step %s: no IntentRunner configured", step.ID)
|
||
st.setStep(idx, step)
|
||
return d.failStep(ctx, st, idx)
|
||
}
|
||
result, err = d.opts.IntentRunner.Run(ctx, IntentStep{
|
||
IntentID: step.IntentID, Slots: step.IntentSlots,
|
||
Title: step.Title, WorkDir: subDir, Targets: targets,
|
||
})
|
||
if err == nil && result != nil && len(result.Patches) > 0 {
|
||
_, _ = exec.MaterializeTargets(subDir, targets, result.Patches)
|
||
}
|
||
} else {
|
||
result, err = backend.Execute(ctx, req)
|
||
}
|
||
if err == nil && result.Success {
|
||
break
|
||
}
|
||
// 交付物物化:后端返回 patches 但未写盘时补写。
|
||
if err == nil && len(result.Patches) > 0 {
|
||
if _, merr := exec.MaterializeTargets(subDir, targets, result.Patches); merr == nil {
|
||
if allTargetsPresent(subDir, targets) {
|
||
result.Success = true
|
||
break
|
||
}
|
||
}
|
||
}
|
||
assessment := exec.AssessStepFailure(exec.StepFailureSignals{
|
||
Err: err, Result: result, WorkDir: subDir,
|
||
ExtraText: fmt.Sprintf("step=%s backend=%s", step.ID, backendName),
|
||
TargetMissing: !allTargetsPresent(subDir, targets),
|
||
})
|
||
if assessment.Class != exec.FailureTransient || attempt == exec.MaxTransientStepInvocations {
|
||
step.Status = "failed"
|
||
if err != nil {
|
||
step.Error = err.Error()
|
||
} else {
|
||
step.Error = orDefault(assessment.Reason, result.ErrorReason)
|
||
}
|
||
break
|
||
}
|
||
}
|
||
|
||
if ctx.Err() != nil {
|
||
step.Status = "pending" // 取消后可恢复
|
||
st.setStep(idx, step)
|
||
return &RunResult{RunID: st.Meta.ID, Status: RunCancelled, Error: "context cancelled",
|
||
Plan: planPtr(st), WorkDir: st.Meta.WorkDir}, nil, nil
|
||
}
|
||
|
||
if result != nil {
|
||
st.addUsage(step.Role, result.PromptTokens, result.CompletionTokens)
|
||
}
|
||
|
||
step.DurationMs = time.Since(started).Milliseconds()
|
||
if step.Status == "failed" {
|
||
d.emit(st, Event{Type: EvStepFailed, StepID: step.ID, Role: step.Role, Text: step.Error, Code: string(errcode.SquadStepFailed)})
|
||
st.setStep(idx, step)
|
||
return d.failStep(ctx, st, idx)
|
||
}
|
||
|
||
// 交付物校验。
|
||
missing := missingTargets(subDir, targets)
|
||
if len(missing) > 0 {
|
||
step.Status, step.Error = "failed", fmt.Sprintf("missing deliverables: %v", missing)
|
||
d.emit(st, Event{Type: EvStepFailed, StepID: step.ID, Role: step.Role, Text: step.Error, Code: string(errcode.AgentMissingDeliverable)})
|
||
st.setStep(idx, step)
|
||
return d.failStep(ctx, st, idx)
|
||
}
|
||
|
||
step.Status = "completed"
|
||
appendLog(subDir, fmt.Sprintf("[%s] backend=%s duration=%dms targets=%v\n", time.Now().Format(time.RFC3339), backendName, step.DurationMs, targets))
|
||
|
||
// 状态回填 + 黑板合并 + 落盘同一临界区:并行步骤的共享状态变更串行化,
|
||
// 防止 marshal 与写回竞争、临时文件互相踩踏把保存失败误判为步骤失败。
|
||
st.mu.Lock()
|
||
st.Plan.Workflow.Steps[idx] = step
|
||
_ = mergeStepArtifacts(&st.BB, subDir)
|
||
err = st.save()
|
||
st.mu.Unlock()
|
||
if err != nil {
|
||
return nil, nil, err
|
||
}
|
||
d.emit(st, Event{Type: EvStepCompleted, StepID: step.ID, Role: step.Role, Text: step.Title,
|
||
Data: marshalJSON(map[string]any{"duration_ms": step.DurationMs, "targets": targets})})
|
||
return nil, nil, nil
|
||
}
|
||
|
||
// failStep 按关键性处理失败步骤:关键 → 运行失败;非关键 → 标记后继续(返回 nil 让循环推进)。
|
||
func (d *flowDriver) failStep(ctx context.Context, st *runState, idx int) (*RunResult, *hitlGate, error) {
|
||
st.mu.Lock()
|
||
step := st.Plan.Workflow.Steps[idx]
|
||
critical := step.IsCritical()
|
||
if critical {
|
||
wf := st.Workflow()
|
||
wf.Status = "failed"
|
||
st.setWorkflow(wf)
|
||
}
|
||
_ = st.save()
|
||
st.mu.Unlock()
|
||
if critical {
|
||
return &RunResult{
|
||
RunID: st.Meta.ID, Status: RunFailed, Plan: planPtr(st), Error: fmt.Sprintf("step %s(%s) failed: %s", step.ID, step.Role, step.Error),
|
||
WorkDir: st.Meta.WorkDir,
|
||
}, nil, nil
|
||
}
|
||
// 非关键降级:标记 failed,工作流继续,收口时 degraded。
|
||
d.emit(st, Event{Type: EvStepSkipped, StepID: step.ID, Role: step.Role,
|
||
Text: "非关键步骤失败,降级继续: " + step.Error})
|
||
return nil, nil, nil
|
||
}
|
||
|
||
// checkBlockingGaps 检查未解决阻塞缺口;interactive 挂起,auto 记录兜底假设并继续。
|
||
func (d *flowDriver) checkBlockingGaps(st *runState) *hitlGate {
|
||
gaps := st.BB.UnresolvedBlockingGaps()
|
||
if len(gaps) == 0 {
|
||
return nil
|
||
}
|
||
mode := d.policy(st).mode()
|
||
if mode == HitlAuto {
|
||
for _, g := range gaps {
|
||
st.BB.Assumptions = append(st.BB.Assumptions, AssumptionItem{
|
||
ID: fmt.Sprintf("asm_%d", len(st.BB.Assumptions)+1), Topic: g.ID,
|
||
Statement: "无人值守模式:未获得人工输入,按现状继续推进(缺口保持未解决)",
|
||
Rationale: g.Item, Source: "fallback",
|
||
})
|
||
for i := range st.BB.Gaps {
|
||
if st.BB.Gaps[i].ID == g.ID {
|
||
st.BB.Gaps[i].Blocking = false
|
||
}
|
||
}
|
||
}
|
||
_ = st.save()
|
||
return nil
|
||
}
|
||
if mode == HitlDisabled {
|
||
return nil // 交由步骤级失败语义处理;缺口不再阻塞
|
||
}
|
||
var lines []string
|
||
for _, g := range gaps {
|
||
lines = append(lines, fmt.Sprintf("- [%s] %s(owner=%s)", g.ID, g.Item, g.Owner))
|
||
}
|
||
waitCond := waitConditionFrom(gaps)
|
||
req := &HumanRequest{
|
||
ID: newRequestID(), RunID: st.Meta.ID, Kind: HitlGap, Severity: SeverityActionRequired,
|
||
WaitCondition: waitCond,
|
||
Question: fmt.Sprintf("发现 %d 个阻塞缺口,需要人工补充信息后才能继续:", len(gaps)),
|
||
Options: []HumanOption{
|
||
{Value: "resolved", Label: "已在回答中补充信息,继续执行"},
|
||
{Value: "skip", Label: "跳过缺口,按现状继续"},
|
||
{Value: "abort", Label: "中止运行"},
|
||
},
|
||
Context: strings.Join(lines, "\n"),
|
||
CreatedAt: time.Now(), Status: "pending",
|
||
}
|
||
gate := &hitlGate{Request: *req, PrevStatus: RunExecuting}
|
||
if err := st.saveGate(gate); err == nil {
|
||
st.Meta.Status = string(RunWaitingHuman)
|
||
_ = st.save()
|
||
d.emitHuman(st, req)
|
||
}
|
||
return gate
|
||
}
|
||
|
||
// applyDynamicAdjustment 评估步骤产出并应用/审批动态调整。
|
||
func (d *flowDriver) applyDynamicAdjustment(ctx context.Context, st *runState, idx int) (*RunResult, *hitlGate, error) {
|
||
step := st.Plan.Workflow.Steps[idx]
|
||
subDir := filepath.Join(st.Meta.WorkDir, filepath.FromSlash(step.SubWorkspace))
|
||
output := readFileOr(filepath.Join(subDir, firstTarget(step.Targets)), "")
|
||
evalRes, err := d.opts.Evaluator.Evaluate(ctx, EvaluateRequest{
|
||
RunID: st.Meta.ID, Step: step, Workspace: st.Meta.WorkDir, Output: output, Blackboard: &st.BB,
|
||
})
|
||
if err != nil || evalRes == nil {
|
||
return nil, nil, err
|
||
}
|
||
for _, na := range evalRes.NewAssumptions {
|
||
st.BB.Assumptions = append(st.BB.Assumptions, na)
|
||
}
|
||
for _, gapID := range evalRes.ResolvedGaps {
|
||
st.BB.ResolveGapByID(gapID, "")
|
||
}
|
||
hasChanges := len(evalRes.DynamicStepsToAdd) > 0 || len(evalRes.StepsToSkip) > 0
|
||
if !hasChanges {
|
||
return nil, nil, nil
|
||
}
|
||
if evalRes.NeedsApproval && d.policy(st).mode() == HitlInteractive {
|
||
var addLines, skipLines []string
|
||
for _, s := range evalRes.DynamicStepsToAdd {
|
||
addLines = append(addLines, fmt.Sprintf("- %s(%s)%s", s.ID, s.Role, s.Title))
|
||
}
|
||
for _, id := range evalRes.StepsToSkip {
|
||
skipLines = append(skipLines, "- "+id)
|
||
}
|
||
req := &HumanRequest{
|
||
ID: newRequestID(), RunID: st.Meta.ID, StepID: step.ID, Kind: HitlAdjustment, Severity: SeverityAttention,
|
||
Question: "评估器建议动态调整工作流,是否应用?",
|
||
Options: []HumanOption{
|
||
{Value: "approve", Label: "应用调整"},
|
||
{Value: "reject", Label: "忽略调整"},
|
||
{Value: "abort", Label: "中止运行"},
|
||
},
|
||
Context: fmt.Sprintf("评估理由:%s\n追加步骤:\n%s\n跳过步骤:\n%s",
|
||
evalRes.Reason, strings.Join(addLines, "\n"), strings.Join(skipLines, "\n")),
|
||
CreatedAt: time.Now(), Status: "pending",
|
||
}
|
||
gate := &hitlGate{Request: *req, Steps: evalRes.DynamicStepsToAdd, SkipIDs: evalRes.StepsToSkip, PrevStatus: RunExecuting}
|
||
if err := st.saveGate(gate); err == nil {
|
||
st.Meta.Status = string(RunWaitingHuman)
|
||
_ = st.save()
|
||
d.emitHuman(st, req)
|
||
}
|
||
return heldResult(st, gate), gate, nil
|
||
}
|
||
applyAdjustment(st, evalRes.DynamicStepsToAdd, evalRes.StepsToSkip)
|
||
return nil, nil, nil
|
||
}
|
||
|
||
// applyAdjustment 应用动态调整:新步骤插到最后一步之前,跳过步骤标记 skipped。
|
||
func applyAdjustment(st *runState, add []StepSpec, skipIDs []string) {
|
||
steps := st.Plan.Workflow.Steps
|
||
for _, id := range skipIDs {
|
||
for i := range steps {
|
||
if steps[i].ID == id && steps[i].Status == "pending" {
|
||
steps[i].Status = "skipped"
|
||
}
|
||
}
|
||
}
|
||
if len(add) > 0 {
|
||
insertAt := len(steps)
|
||
if insertAt > 0 {
|
||
insertAt-- // 插在最后(成文/终审)步骤之前
|
||
}
|
||
for i := range add {
|
||
add[i].Status = "pending"
|
||
if add[i].Targets == nil {
|
||
add[i].Targets = []string{"notes.md", "fields.json"}
|
||
}
|
||
}
|
||
steps = append(steps[:insertAt], append(append([]StepSpec{}, add...), steps[insertAt:]...)...)
|
||
}
|
||
st.Plan.Workflow.Steps = steps
|
||
_ = st.save()
|
||
}
|
||
|
||
// finalize 收口:汇总交付物 → 质检 → 自动修复 → (必要时)人工裁决 → 清单。
|
||
func (d *flowDriver) finalize(ctx context.Context, st *runState) (*RunResult, *hitlGate, error) {
|
||
wf := st.Workflow()
|
||
degraded := false
|
||
for _, s := range wf.Steps {
|
||
if s.Status == "failed" || s.Status == "skipped" {
|
||
degraded = true
|
||
}
|
||
}
|
||
// 汇总交付物:最后一个 completed 步骤的主产出 → output.md。
|
||
var srcStep *StepSpec
|
||
for i := range wf.Steps {
|
||
if wf.Steps[i].Status == "completed" {
|
||
srcStep = &wf.Steps[i]
|
||
}
|
||
}
|
||
if srcStep == nil {
|
||
wf.Status = "failed"
|
||
st.setWorkflow(wf)
|
||
_ = st.save()
|
||
return &RunResult{RunID: st.Meta.ID, Status: RunFailed, Plan: planPtr(st),
|
||
Error: "no completed step to assemble deliverable", WorkDir: st.Meta.WorkDir}, nil, nil
|
||
}
|
||
src := filepath.Join(st.Meta.WorkDir, filepath.FromSlash(srcStep.SubWorkspace), firstTarget(srcStep.Targets))
|
||
data, err := os.ReadFile(src)
|
||
if err != nil {
|
||
wf.Status = "failed"
|
||
st.setWorkflow(wf)
|
||
_ = st.save()
|
||
return &RunResult{RunID: st.Meta.ID, Status: RunFailed, Plan: planPtr(st),
|
||
Error: "deliverable source missing: " + err.Error(), WorkDir: st.Meta.WorkDir}, nil, nil
|
||
}
|
||
deliverable := "output.md"
|
||
if err := os.WriteFile(filepath.Join(st.Meta.WorkDir, deliverable), data, 0o644); err != nil {
|
||
return nil, nil, err
|
||
}
|
||
d.emit(st, Event{Type: EvDeliverable, Text: deliverable})
|
||
|
||
// 质检 + 有界自动修复。
|
||
report := d.opts.Checker.Check(CheckRequest{
|
||
RunID: st.Meta.ID, Workspace: st.Meta.WorkDir, Deliverable: deliverable,
|
||
Plan: planPtr(st), Squad: &st.Plan.Squad, Template: st.Template,
|
||
})
|
||
if srcStep.MinScore > 0 && report.Score < float64(srcStep.MinScore) {
|
||
report.Passed = false
|
||
report.Violations = append(report.Violations, fmt.Sprintf("总分 %.0f 低于步骤门槛 %d", report.Score, srcStep.MinScore))
|
||
}
|
||
if !report.Passed && st.RepairBudgetRemaining > 0 {
|
||
st.RepairBudgetRemaining--
|
||
_ = st.save()
|
||
// 重跑交付物源步骤一次,再检一次。
|
||
if out, g, err := d.rerunForRepair(ctx, st, srcStep.ID); out != nil || g != nil || err != nil {
|
||
return out, g, err
|
||
}
|
||
data2, _ := os.ReadFile(src)
|
||
_ = os.WriteFile(filepath.Join(st.Meta.WorkDir, deliverable), data2, 0o644)
|
||
report = d.opts.Checker.Check(CheckRequest{
|
||
RunID: st.Meta.ID, Workspace: st.Meta.WorkDir, Deliverable: deliverable,
|
||
Plan: planPtr(st), Squad: &st.Plan.Squad, Template: st.Template,
|
||
})
|
||
}
|
||
|
||
if !report.Passed {
|
||
switch d.policy(st).mode() {
|
||
case HitlDisabled, HitlAuto:
|
||
wf.Status = "failed"
|
||
st.setWorkflow(wf)
|
||
_ = st.save()
|
||
return &RunResult{RunID: st.Meta.ID, Status: RunFailed, Plan: planPtr(st),
|
||
Quality: report, Error: "quality check failed (hitl mode=" + string(d.policy(st).mode()) + "): " + strings.Join(report.Violations, "; "),
|
||
WorkDir: st.Meta.WorkDir}, nil, nil
|
||
default:
|
||
req := &HumanRequest{
|
||
ID: newRequestID(), RunID: st.Meta.ID, Kind: HitlQualityReview, Severity: SeverityActionRequired,
|
||
Question: "交付物质检未通过且自动修复预算已用尽,请人工裁决:",
|
||
Options: []HumanOption{
|
||
{Value: "approve", Label: "人工放行(记录人工通过)"},
|
||
{Value: "retry", Label: "再修复一次(重跑交付步骤)"},
|
||
{Value: "abort", Label: "判定失败"},
|
||
},
|
||
Context: string(marshalJSON(report)),
|
||
CreatedAt: time.Now(), Status: "pending",
|
||
}
|
||
gate := &hitlGate{Request: *req, PrevStatus: RunExecuting}
|
||
if err := st.saveGate(gate); err == nil {
|
||
st.Meta.Status = string(RunQualityReview)
|
||
_ = st.save()
|
||
d.emitHuman(st, req)
|
||
}
|
||
return heldResult(st, gate), gate, nil
|
||
}
|
||
}
|
||
|
||
status := RunCompleted
|
||
if degraded {
|
||
status = RunDegraded
|
||
}
|
||
now := time.Now()
|
||
wf.Status = string(status)
|
||
st.setWorkflow(wf)
|
||
st.Meta.Status = string(status)
|
||
st.Meta.CompletedAt = &now
|
||
_ = st.save()
|
||
|
||
manifest := buildManifest(st, deliverable, *report)
|
||
_ = writeJSON(filepath.Join(st.Meta.WorkDir, "manifest.json"), manifest)
|
||
|
||
d.emit(st, Event{Type: EvRunCompleted, Text: string(status),
|
||
Data: marshalJSON(map[string]any{"deliverable": deliverable, "score": report.Score})})
|
||
return &RunResult{
|
||
RunID: st.Meta.ID, Status: status, Plan: planPtr(st), Deliverable: deliverable,
|
||
Quality: report, WorkDir: st.Meta.WorkDir, Usage: st.usageSnapshot(),
|
||
Summary: summarize(st, *report, status),
|
||
}, nil, nil
|
||
}
|
||
|
||
// rerunForRepair 重置交付物源步骤并重新执行到收口。
|
||
func (d *flowDriver) rerunForRepair(ctx context.Context, st *runState, stepID string) (*RunResult, *hitlGate, error) {
|
||
for i := range st.Plan.Workflow.Steps {
|
||
if st.Plan.Workflow.Steps[i].ID == stepID {
|
||
st.Plan.Workflow.Steps[i].Status = "pending"
|
||
st.Plan.Workflow.Steps[i].Error = ""
|
||
note, err := os.ReadFile(filepath.Join(st.Meta.WorkDir, "quality-feedback.md"))
|
||
if err == nil && len(bytes.TrimSpace(note)) > 0 {
|
||
taskPath := filepath.Join(st.Meta.WorkDir, filepath.FromSlash(st.Plan.Workflow.Steps[i].SubWorkspace), "TASK.md")
|
||
existing, _ := os.ReadFile(taskPath)
|
||
_ = os.WriteFile(taskPath, append(existing, append([]byte("\n\n"), note...)...), 0o644)
|
||
}
|
||
}
|
||
}
|
||
d.emit(st, Event{Type: EvPhase, Text: "质检未通过,自动修复:重跑交付步骤 " + stepID})
|
||
return d.advance(ctx, st)
|
||
}
|
||
|
||
// heldResult 由挂起上下文构造对外结果(挂起路径统一返回非 nil 结果)。
|
||
func heldResult(st *runState, gate *hitlGate) *RunResult {
|
||
status := RunWaitingHuman
|
||
switch gate.Request.Kind {
|
||
case HitlClarification:
|
||
status = RunAwaitingClarification
|
||
case HitlConfirmation:
|
||
status = RunAwaitingConfirmation
|
||
case HitlQualityReview:
|
||
status = RunQualityReview
|
||
}
|
||
return &RunResult{
|
||
RunID: st.Meta.ID, Status: status, Plan: planPtr(st),
|
||
Pending: &gate.Request, WorkDir: st.Meta.WorkDir, Usage: st.usageSnapshot(),
|
||
}
|
||
}
|
||
|
||
// waitConditionFrom 从阻塞缺口提取等待条件(首个声明者胜出)。
|
||
func waitConditionFrom(gaps []GapItem) *WaitCondition {
|
||
for _, g := range gaps {
|
||
if g.WaitRunID != "" {
|
||
return &WaitCondition{Type: "run_done", RunID: g.WaitRunID}
|
||
}
|
||
if g.WaitField != "" {
|
||
return &WaitCondition{Type: "field_set", Field: g.WaitField}
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// hold 生成挂起请求并返回结果。
|
||
func (d *flowDriver) hold(st *runState, kind HumanRequestKind, stepID, role, question string, options []HumanOption, context, reason string, extra *hitlGate) (*RunResult, *hitlGate, error) {
|
||
req := &HumanRequest{
|
||
ID: newRequestID(), RunID: st.Meta.ID, StepID: stepID, Kind: kind,
|
||
Severity: SeverityActionRequired,
|
||
Question: question, Options: options, Context: context, Reason: reason,
|
||
CreatedAt: time.Now(), Status: "pending",
|
||
}
|
||
status := RunWaitingHuman
|
||
switch kind {
|
||
case HitlClarification:
|
||
status = RunAwaitingClarification
|
||
case HitlConfirmation:
|
||
status = RunAwaitingConfirmation
|
||
}
|
||
gate := extra
|
||
if gate == nil {
|
||
gate = &hitlGate{}
|
||
}
|
||
gate.Request = *req
|
||
gate.PrevStatus = RunExecuting
|
||
if err := st.saveGate(gate); err != nil {
|
||
return nil, nil, err
|
||
}
|
||
st.Meta.Status = string(status)
|
||
_ = st.save()
|
||
d.emitHuman(st, req)
|
||
return &RunResult{RunID: st.Meta.ID, Status: status, Plan: planPtr(st),
|
||
Pending: req, WorkDir: st.Meta.WorkDir}, nil, nil
|
||
}
|
||
|
||
// policy 合并引擎全局策略与 Goal 覆盖(窄者胜)。
|
||
// policyRole 在引擎全局/Goal 链之上叠加角色级 HitlPolicy
|
||
// (窄者胜;Goal 仍最高)。
|
||
func (d *flowDriver) policyRole(st *runState, role RoleSpec) HitlPolicy {
|
||
p := d.policy(st)
|
||
if role.HitlPolicy != nil {
|
||
p = mergeHitlPolicy(p, role.HitlPolicy)
|
||
}
|
||
return p
|
||
}
|
||
|
||
func (d *flowDriver) policy(st *runState) HitlPolicy {
|
||
return mergeHitlPolicy(d.opts.HitlPolicy, st.Meta.HitlPolicyOf())
|
||
}
|
||
|
||
func (d *flowDriver) emit(st *runState, ev Event) {
|
||
if d.opts.Sink == nil {
|
||
return
|
||
}
|
||
ev.RunID = st.Meta.ID
|
||
if ev.At.IsZero() {
|
||
ev.At = time.Now()
|
||
}
|
||
d.opts.Sink.Emit(ev)
|
||
}
|
||
|
||
func (d *flowDriver) emitHuman(st *runState, req *HumanRequest) {
|
||
d.emit(st, Event{Type: EvHumanInputRequired, Text: req.Question, Data: marshalJSON(req)})
|
||
}
|
||
|
||
func summarize(st *runState, report QualityReport, status RunStatus) string {
|
||
var b strings.Builder
|
||
fmt.Fprintf(&b, "运行 %s:%s。步骤 %d 个(完成 %d/跳过 %d/失败 %d)。质检得分 %.0f。",
|
||
st.Meta.ID, status, len(st.Plan.Workflow.Steps),
|
||
countStatus(st, "completed"), countStatus(st, "skipped"), countStatus(st, "failed"), report.Score)
|
||
if gaps := st.BB.UnresolvedBlockingGaps(); len(gaps) > 0 {
|
||
fmt.Fprintf(&b, "未解决缺口 %d 个。", len(gaps))
|
||
}
|
||
return b.String()
|
||
}
|
||
|
||
func countStatus(st *runState, status string) int {
|
||
n := 0
|
||
for _, s := range st.Plan.Workflow.Steps {
|
||
if s.Status == status {
|
||
n++
|
||
}
|
||
}
|
||
return n
|
||
}
|
||
|
||
func buildManifest(st *runState, deliverable string, report QualityReport) Manifest {
|
||
files := map[string]string{}
|
||
_ = filepath.Walk(st.Meta.WorkDir, func(path string, info os.FileInfo, err error) error {
|
||
if err != nil || info.IsDir() {
|
||
return nil
|
||
}
|
||
rel, _ := filepath.Rel(st.Meta.WorkDir, path)
|
||
files[filepath.ToSlash(rel)] = sha256File(path)
|
||
return nil
|
||
})
|
||
start := st.Meta.CreatedAt
|
||
return Manifest{
|
||
ManifestVersion: "1", WorkspaceID: st.Meta.ID, GeneratedAt: time.Now(),
|
||
Deliverable: deliverable, Files: files, QualityReport: report,
|
||
DurationMs: time.Since(start).Milliseconds(),
|
||
}
|
||
}
|
||
|
||
func firstTarget(targets []string) string {
|
||
if len(targets) > 0 {
|
||
return targets[0]
|
||
}
|
||
return "output.md"
|
||
}
|
||
|
||
func allTargetsPresent(dir string, targets []string) bool {
|
||
return len(missingTargets(dir, targets)) == 0
|
||
}
|
||
|
||
func missingTargets(dir string, targets []string) []string {
|
||
var missing []string
|
||
for _, t := range targets {
|
||
if strings.TrimSpace(t) == "" {
|
||
continue
|
||
}
|
||
info, err := os.Stat(filepath.Join(dir, filepath.FromSlash(t)))
|
||
if err != nil || info.Size() == 0 {
|
||
missing = append(missing, t)
|
||
}
|
||
}
|
||
return missing
|
||
}
|
||
|
||
func readFileOr(path, fallback string) string {
|
||
if data, err := os.ReadFile(path); err == nil && len(strings.TrimSpace(string(data))) > 0 {
|
||
return string(data)
|
||
}
|
||
return fallback
|
||
}
|
||
|
||
func appendLog(dir, line string) {
|
||
f, err := os.OpenFile(filepath.Join(dir, "execution.log"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
|
||
if err != nil {
|
||
return
|
||
}
|
||
defer f.Close()
|
||
_, _ = f.WriteString(line)
|
||
}
|
||
|
||
func planPtr(st *runState) *WorkflowPlan {
|
||
wf := st.Workflow()
|
||
return &wf
|
||
}
|
||
|
||
func orDefault(v, def string) string {
|
||
if v == "" {
|
||
return def
|
||
}
|
||
return v
|
||
}
|