Why: A step confirmation cancelled the whole run, approvals and repair budget vanished on restart, and the HTTP server accepted anonymous and oversized requests. Evidence: GOWORK=off go vet ./... && GOWORK=off go test -count=1 ./... && GOWORK=off go test -race ./squad/... passed. scripts/submodule-consistency.sh ok. Impact: Approve and skip apply to one step; runstate.json and unix flock survive restart; wakeup leaves permission holds pending; serve defaults to loopback and requires a token off-loopback. Refs: Q1 Q2 Q3 Q4 Q5 Q8 Q9 Q14 Q15 Q16 Co-authored-by: Cursor <cursoragent@cursor.com>
817 lines
27 KiB
Go
817 lines
27 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
|
||
}
|
||
|
||
// HITL:角色权限升级(bash=ask 且本运行未授权过该角色)。
|
||
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)
|
||
}
|
||
}
|
||
|
||
// HITL:步骤显式要求审批(角色级策略参与判定)。
|
||
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)
|
||
}
|
||
}
|
||
|
||
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" {
|
||
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)
|
||
}
|
||
|
||
// 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
|
||
}
|