Files
Agent-Squad/squad/workflow.go
T
weichen 23f161bfb3 Honor executor terminals in retries and interrupted recovery
Why: F05 - files-presence could promote failures to success, recovery
marked crashed steps completed on files alone or reran them blindly,
and retries minted a fresh ExecutionID each attempt.
Evidence: squad tests pass incl. executor result contract and recovery
tables (verify-u3b.json); (nil,nil), canceled and side-effect-possible
semantics handled; interrupted steps without a trusted terminal now
hold for reconciliation instead of auto-advancing.
Impact: retries reuse the step ExecutionID and shared deadline; no
auto-replay of unverified external actions.
Refs: feature/agent-stack-audit-remediation-b1 U3B
2026-10-21 17:20:40 +00:00

1250 lines
44 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"
"os"
"path/filepath"
"strings"
"sync"
"time"
"error-code-system/errcode"
)
// flowDriver 工作流驱动器:推进 RunState 直到终态或 HITL 挂起边界。
type flowDriver struct {
opts engineDeps
}
// advance 执行到边界;返回 (结果, 挂起上下文),二者互斥非 nil(infra 错误时都为 nil 且 err 非 nil)。
// 推进模型(Q1 WP1/WP2):每轮从权威 Steps 现场编译调度图,按 superstep
// 分层推进——层内无写集冲突的步骤并行(runParallel 复用),有冲突的按声明序
// 串行(含动态调整);动态调整改 Steps 后下一轮自然生效。
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: "开始执行工作流"})
superNo := 0
// 运行级截止(Q2 long_running):superstep 边界检查,超期未完步骤置
// skipped 并降级收口;零值/不可解析 = 不设限。
var runDeadline time.Time
if v := st.Meta.Metadata["deadline"]; v != "" {
// RFC3339Nano 同时接受秒级与纳秒级两种写法。
if t, terr := time.Parse(time.RFC3339Nano, v); terr == nil {
runDeadline = t
}
}
cpw := newCheckpointWriter(CheckpointMode(st.Meta.Metadata["checkpoint_mode"]), st.storeOr())
// 推进退出前 flush:async 落完、on_exit 写最终快照(Q1 WP4 三档的出口语义)。
var lastGraph *graph
defer func() {
if lastGraph == nil {
if g, gerr := compileGraph(st.Plan.Workflow.Steps); gerr == nil {
lastGraph = g
}
}
if lastGraph != nil {
_ = cpw.flush(st.Meta.WorkDir, snapshotOf(superNo, lastGraph, st.Plan.Workflow.Steps))
}
}()
for {
if !runDeadline.IsZero() && time.Now().After(runDeadline) {
d.emit(st, Event{Type: EvPhase, Text: "运行截止时刻已到,未完步骤按 skipped 收口"})
st.mu.Lock()
for i := range st.Plan.Workflow.Steps {
if st.Plan.Workflow.Steps[i].Status == "pending" || st.Plan.Workflow.Steps[i].Status == "running" {
st.Plan.Workflow.Steps[i].Status = "skipped"
st.Plan.Workflow.Steps[i].Error = "deadline exceeded"
}
}
_ = st.save()
st.mu.Unlock()
return d.finalize(ctx, st)
}
g, gerr := compileGraph(st.Plan.Workflow.Steps)
if gerr != nil {
wf2 := st.Workflow()
wf2.Status = "failed"
st.setWorkflow(wf2)
return &RunResult{RunID: st.Meta.ID, Status: RunFailed, Plan: planPtr(st),
Error: "graph: " + gerr.Error(), WorkDir: st.Meta.WorkDir}, nil, nil
}
layer, ok := g.nextLayer(st.Plan.Workflow.Steps)
if !ok {
if n := pendingCount(st.Plan.Workflow.Steps); n > 0 {
wf2 := st.Workflow()
wf2.Status = "failed"
st.setWorkflow(wf2)
return &RunResult{RunID: st.Meta.ID, Status: RunFailed, Plan: planPtr(st),
Error: fmt.Sprintf("graph: 依赖无法满足,仍有 %d 个步骤无法调度", n), WorkDir: st.Meta.WorkDir}, nil, nil
}
break
}
superNo++
d.emit(st, Event{Type: EvPhase, Text: fmt.Sprintf("superstep %d 推进(并行 %d 步,串行 %d 步)", superNo, len(layer.par), len(layer.seq))})
// 并行段:组内每一步都先过审批和权限,任一需要挂起则整组不执行。
if len(layer.par) > 0 {
for _, idx := range layer.par {
if out, g2, err := d.gateBeforeRun(ctx, st, idx); out != nil || g2 != nil || err != nil {
if out == nil && g2 != nil {
out = heldResult(st, g2)
}
return out, g2, err
}
}
if out, g2, err := d.runParallel(ctx, st, layer.par); out != nil || g2 != nil || err != nil {
if out == nil && g2 != nil {
out = heldResult(st, g2)
}
return out, g2, err
}
if g2 := d.checkBlockingGaps(st); g2 != nil {
return heldResult(st, g2), g2, nil
}
// 单成员并行组实际是顺序执行:与旧线性扫描一致,其后可动态调整
// (多成员组不做,避免并发竞态)。
if len(layer.par) == 1 {
idx := layer.par[0]
if st.Plan.Workflow.Steps[idx].AllowDynamicAdjustment && d.opts.Evaluator != nil {
out, g2, err := d.applyDynamicAdjustment(ctx, st, idx)
if out != nil || g2 != nil || err != nil {
if out == nil && g2 != nil {
out = heldResult(st, g2)
}
return out, g2, err
}
}
}
}
// 串行段(写集冲突步骤,按声明序逐个执行;顺序步骤后可动态调整)。
for _, idx := range layer.seq {
if out, g2, err := d.gateBeforeRun(ctx, st, idx); out != nil || g2 != nil || err != nil {
if out == nil && g2 != nil {
out = heldResult(st, g2)
}
return out, g2, err
}
out, g2, err := d.runOneStep(ctx, st, idx)
if out != nil || g2 != nil || err != nil {
if out == nil && g2 != nil {
out = heldResult(st, g2)
}
return out, g2, err
}
if g2 := d.checkBlockingGaps(st); g2 != nil {
return heldResult(st, g2), g2, nil
}
if st.Plan.Workflow.Steps[idx].AllowDynamicAdjustment && d.opts.Evaluator != nil {
out, g2, err := d.applyDynamicAdjustment(ctx, st, idx)
if out != nil || g2 != nil || err != nil {
if out == nil && g2 != nil {
out = heldResult(st, g2)
}
return out, g2, err
}
}
}
// superstep 边界快照(sync 立即 / async 入队;on_exit 由 defer 的 flush 写)。
lastGraph = g
if err := cpw.boundary(st.Meta.WorkDir, snapshotOf(superNo, g, st.Plan.Workflow.Steps)); err != nil {
return nil, nil, err
}
}
return d.finalize(ctx, st)
}
// snapshotOf 组装一个 superstep 边界快照:序号、图结构指纹与全量步骤状态。
// terminal side-effect evidence 由 recordStepTerminal 在步骤进入 completed 时写入
// step_terminals.json;本处把已记录的 terminal 数据并入 StepSnapshot,保证
// checkpoint 也带同样证据(F05:信任落盘记录)。
func snapshotOf(superNo int, g *graph, steps []StepSpec) Checkpoint {
cp := Checkpoint{Superstep: superNo, At: time.Now(), GraphHash: g.hash()}
// checkpoint 路径拿不到当前 RunState 引用(cpw 在 defer flush 时调用过),
// 同时也仅有顺序式调用传递上下文不便;所以这里走「按 ID 检查同索引」对齐
// 替代——checkpoint 产生时 step.Status 已为终态(completed),因此 record 同步
// 已写入;StepSnapshot.Terminal 就用 step.Status 推导(F05 唯一信任源)。
for _, s := range steps {
snap := StepSnapshot{ID: s.ID, Status: s.Status, AttemptID: s.AttemptID}
if s.Status == "completed" {
snap.Terminal = "completed"
}
cp.StepStates = append(cp.StepStates, snap)
}
return cp
}
// 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) {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
var wg sync.WaitGroup
var mu sync.Mutex
failedCritical := -1
var firstFail *RunResult
var gate *HitlGate
for _, idx := range idxs {
wg.Add(1)
go func(idx int) {
defer wg.Done()
out, g, err := d.runOneStep(ctx, st, idx)
mu.Lock()
defer mu.Unlock()
if g != nil && gate == nil {
gate = g
cancel()
}
if (out != nil && out.Status == RunFailed) || err != nil {
cancel()
// 保留首个带具体错误的失败结果(如 router 无命中);
// failStep 的通用格式只在没有更具体结果时兜底。
if out != nil && out.Status == RunFailed && firstFail == nil {
firstFail = out
}
if st.Plan.Workflow.Steps[idx].IsCritical() && failedCritical < 0 {
failedCritical = idx
}
}
}(idx)
}
wg.Wait()
if gate != nil {
return heldResult(st, gate), gate, nil
}
if firstFail != nil {
return firstFail, nil, nil
}
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) {
if err := ctx.Err(); err != nil {
return &RunResult{RunID: st.Meta.ID, Status: RunCancelled, Error: err.Error()}, nil, err
}
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 if step.IntentID != "" {
role = RoleSpec{Role: step.Role, Name: step.Role}
} else {
step.Status, step.Error = "failed", "role not found: "+step.Role
st.setStep(idx, step)
return d.failStep(ctx, st, idx)
}
}
// handoff 原语 fail-closed(Q2):目标角色必须可解析,启动前拒绝。
if step.Handoff != "" {
if _, ok := findAgent(st.Plan.Squad.Agents, step.Handoff); !ok {
if _, exists := d.opts.Registry.Get(step.Handoff); !exists {
step.Status, step.Error = "failed", "handoff: 未知目标角色 "+step.Handoff
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.AttemptID = fmt.Sprintf("%s/%s/%d", st.Meta.ID, step.ID, 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()
if d.opts.Workspaces != nil {
if err := d.opts.Workspaces.writeTaskFile(subDir, &step, role, &st.BB); err != nil {
return nil, nil, err
}
}
prompt := readFileOr(filepath.Join(subDir, "TASK.md"), step.Title)
prompt = st.injectSteering(prompt)
// 邮箱投递(Q2):本步骤启动时把发给该角色(含广播)的未读消息并入
// 任务书;只走 prompt 面,永不触碰 HITL 门(验收三构造性保证)。
st.mu.Lock()
mail := takeMailFor(st, step.Role)
handoff := takeHandoffFor(st, step.Role)
if len(mail) > 0 || handoff != nil {
_ = st.save()
}
st.mu.Unlock()
prompt += formatMailSection(mail)
// handoff 原语(Q2):目标角色的步骤接收过滤历史(只带移交方主产出)。
prompt += formatHandoffSection(handoff)
instructions := readFileOr(filepath.Join(subDir, "AGENTS.md"), d.opts.Workspaces.ResolveInstructions(role))
var backend Executor
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 *ExecBudget
if d.opts.StepTimeout > 0 {
stepBudget = &ExecBudget{Deadline: time.Now().Add(d.opts.StepTimeout)}
}
var result *ExecResult
var err error
// F05/U3b(KTD4):ExecutionID 在一次逻辑执行内稳定。重试层共享同一 ID
// + 同一 Deadline,便于下游做幂等键 + 收据对齐。第一层 attempt 用稳定 ID;
// 重试层沿用 step.AttemptID(首次进入时已生成、保持不变)。
for attempt := 1; attempt <= 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 := ExecRequest{
WorkDir: subDir,
Prompt: reqPrompt,
Instructions: instructions,
Targets: targets,
Timeout: d.opts.StepTimeout,
ToolProfile: orDefault(role.ToolProfile, ToolProfileWorkspace),
OnEvent: func(ev 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),
// F05:ExecutionID 复用 step.AttemptID(首层 attempt 时已生成)——重试层不再换新 ID。
SessionScope: st.Meta.ID + "/" + step.ID,
ExecutionID: step.AttemptID,
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 {
_, _ = MaterializeTargets(subDir, targets, result.Patches)
}
} else {
result, err = backend.Execute(ctx, req)
}
// F05/KTD4:先验证执行结果本身,不让「文件已存在」翻成 Success。
// (1) (nil,nil) 视作稳定失败,不允许 panic 也不允许误判成功;
// (2) Success=true 仅在 result 显式声明时才成立;
// (3) 已取消/中止的结果不进入重试通路(不是业务失败,且重试会重复已发生的取消语义)。
if err != nil && result == nil {
// (nil,nil) 在 Go 是不可能态(要么 err、要么 result);保险起见仍按稳定失败。
step.Status = "failed"
step.Error = "executor returned no result and no error (stable failure)"
break
}
if result == nil {
step.Status = "failed"
step.Error = "executor returned nil result"
break
}
if result.IsCanceled() {
// F05/KTD4:取消不是业务失败,不重试(按 attempt==MaxTransientStepInvocations 跳出)。
step.Status = "failed"
if result.ErrorReason != "" {
step.Error = "executor canceled: " + result.ErrorReason
} else {
step.Error = "executor canceled"
}
break
}
if err == nil && result.Success {
// 交付物物化:后端返回 patches 但未写盘时补写。F05:仅当 result.Success
// 已验证且文件未准备时才需要物化;只有 patches 内容才能落盘。
if len(result.Patches) > 0 {
_, _ = MaterializeTargets(subDir, targets, result.Patches)
}
break
}
// 交付物物化:后端返回 patches 但未写盘时补写。F05:仅当 result.Success 已
// 被后端显式声明时物化才允许 break;旧逻辑会让
// `err==nil && len(Patches)>0 && materialize_ok` 翻成 Success=true —— 修掉。
if err == nil && len(result.Patches) > 0 {
if _, merr := MaterializeTargets(subDir, targets, result.Patches); merr == nil {
if result.Success && allTargetsPresent(subDir, targets) {
break
}
}
}
// F05/KTD4:副作用可能存在的失败禁止自动重试(重试会重复外部动作)。
// 仅 SideEffects=none 或纯读取后的失败保持有界重试。
if !result.IsRetryableSideEffectReports() && err != nil {
step.Status = "failed"
step.Error = orDefault(result.ErrorReason, err.Error())
if result.ErrorReason != "" {
step.Error = result.ErrorReason
}
break
}
assessment := AssessStepFailure(StepFailureSignals{
Err: err, Result: result, WorkDir: subDir,
ExtraText: fmt.Sprintf("step=%s backend=%s", step.ID, backendName),
TargetMissing: !allTargetsPresent(subDir, targets),
})
if assessment.Class != FailureTransient || attempt == 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))
// F05/U3b:记录 trusted terminal state(用于 recoverInterrupted 判定)。
// 由「正在收口的 completed」流写入;recovery 期间不会回填(防伪)。
d.recordStepTerminal(st, step, result)
// 状态回填 + 黑板合并 + 落盘同一临界区:并行步骤的共享状态变更串行化,
// 防止 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})})
// handoff 原语(Q2):完成即移交最终答案所有权——记录 + 主产出快照,
// 供目标角色下一步骤(过滤历史)与 finalize(链尾产出)消费。
if step.Handoff != "" {
d.recordHandoff(st, step, subDir, targets)
}
// 循环步再武装(Q2 loop):停机条件不满足且预算未尽 → 本步与 loop.body
// 一起重置为 pending;触顶发 loop_exhausted 事件后按完成继续。
if step.Loop != nil {
if d.loopShouldRearm(st, step) {
if out, g2, err2 := d.rearmLoop(ctx, st, idx, step); out != nil || g2 != nil || err2 != nil {
return out, g2, err2
}
return nil, nil, nil
}
if step.Loop.UntilField != "" && step.LoopRuns+1 >= step.Loop.MaxIterations {
d.emit(st, Event{Type: EvPhase, StepID: step.ID, Text: fmt.Sprintf("loop_exhausted:%s 达 %d 轮上限,停机条件未满足", step.ID, step.Loop.MaxIterations)})
}
}
// 路由步分支裁决(Q2 router):首个命中的分支保留,其余分支目标置
// skipped;无分支命中 fail-closed 成失败运行。
if len(step.Branches) > 0 {
return d.applyBranches(ctx, st, idx, step)
}
return nil, nil, nil
}
// loopShouldRearm 判定循环步是否再武装:停机条件(黑板字段等值)满足或
// 预算用尽则停。字段判据读的是本轮 mergeStepArtifacts 之后的黑板。
func (d *flowDriver) loopShouldRearm(st *RunState, step StepSpec) bool {
// MaxIterations 是总迭代上限(含首跑):已跑 LoopRuns+1 次。
if step.LoopRuns+1 >= step.Loop.MaxIterations {
return false
}
if step.Loop.UntilField != "" {
st.mu.Lock()
v := st.BB.Fields[step.Loop.UntilField]
st.mu.Unlock()
if v == step.Loop.UntilEquals {
return false
}
}
return true
}
// rearmLoop 把循环步与 loop.body 一起重置为 pending(同一临界区内落盘)。
func (d *flowDriver) rearmLoop(_ context.Context, st *RunState, idx int, step StepSpec) (*RunResult, *HitlGate, error) {
st.mu.Lock()
step.LoopRuns++
step.Status = "pending"
step.Error = ""
st.Plan.Workflow.Steps[idx] = step
for _, bodyID := range step.Loop.Body {
for j := range st.Plan.Workflow.Steps {
if st.Plan.Workflow.Steps[j].ID == bodyID && st.Plan.Workflow.Steps[j].Status == "completed" {
st.Plan.Workflow.Steps[j].Status = "pending"
st.Plan.Workflow.Steps[j].Error = ""
}
}
}
err := st.save()
round := step.LoopRuns
st.mu.Unlock()
if err != nil {
return nil, nil, err
}
d.emit(st, Event{Type: EvPhase, StepID: step.ID, Text: fmt.Sprintf("loop 第 %d 轮再武装:%s", round+1, step.ID)})
return nil, nil, nil
}
// applyBranches 裁决路由步分支:When 按声明序取首个命中;未选中分支的
// pending 目标置 skipped(skipped=依赖已满足,join 步照常推进)。
func (d *flowDriver) applyBranches(_ context.Context, st *RunState, _ int, step StepSpec) (*RunResult, *HitlGate, error) {
st.mu.Lock()
fields := map[string]string{}
for k, v := range st.BB.Fields {
fields[k] = v
}
st.mu.Unlock()
var selected *Branch
for i := range step.Branches {
if branchMatches(fields, step.Branches[i].When) {
selected = &step.Branches[i]
break
}
}
if selected == nil {
wf := st.Workflow()
wf.Status = "failed"
st.setWorkflow(wf)
_ = st.save()
return &RunResult{RunID: st.Meta.ID, Status: RunFailed, Plan: planPtr(st),
Error: fmt.Sprintf("router: 步骤 %s 无分支命中(fail-closed)", step.ID), WorkDir: st.Meta.WorkDir}, nil, nil
}
keep := map[string]bool{}
for _, t := range selected.Then {
keep[t] = true
}
skipped := 0
st.mu.Lock()
for j := range st.Plan.Workflow.Steps {
sj := &st.Plan.Workflow.Steps[j]
if sj.ID == step.ID || keep[sj.ID] || sj.Status != "pending" {
continue
}
for _, br := range step.Branches {
inBranch := false
for _, t := range br.Then {
if t == sj.ID {
inBranch = true
break
}
}
if inBranch {
sj.Status = "skipped"
sj.Error = routerSkipReason
skipped++
break
}
}
}
err := st.save()
st.mu.Unlock()
if err != nil {
return nil, nil, err
}
d.emit(st, Event{Type: EvPhase, StepID: step.ID,
Text: fmt.Sprintf("router %s 选中分支 %v(跳过 %d 步)", step.ID, selected.Then, skipped)})
return nil, nil, nil
}
// routerSkipReason 是分支落选的 skipped 标记(finalize 的降级豁免凭据)。
const routerSkipReason = "router: 分支未选中"
// branchMatches 判定分支条件:Equals 非空须相等,NotEquals 非空须不等;
// 字段未设时按零值 "" 参与比较(compileGraph 已保证两者至少其一非空)。
func branchMatches(fields map[string]string, c BranchCond) bool {
v := fields[c.Field]
if c.Equals != "" && v != c.Equals {
return false
}
if c.NotEquals != "" && v == c.NotEquals {
return false
}
return true
}
// 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" {
degraded = true
}
// router 分支落选是预期路径,不算降级(Q2)。
if s.Status == "skipped" && s.Error != routerSkipReason {
degraded = true
}
}
// 汇总交付物:最后一个 completed 步骤的主产出 → output.md。
// handoff 链尾优先(Q2):有所有权移交时,交付物取链尾角色的最后一个
// completed 步骤产出(最终答案所有权在链尾),无则该角色无完成步骤时回落。
var srcStep *StepSpec
for i := range wf.Steps {
if wf.Steps[i].Status == "completed" {
srcStep = &wf.Steps[i]
}
}
if tail := handoffChainTail(st); tail != "" {
for i := range wf.Steps {
if wf.Steps[i].Status == "completed" && wf.Steps[i].Role == tail {
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
}
// recordStepTerminal 记一条「trusted terminal record」:在某步执行完成
// (仅 success 路径)时落地 step.Terminal + SideEffects + canceled/aborted。
// reconcile/path:如果 recovery 期间没有该记录,则按文件存在与否分流
// (见 engine.recoverInterrupted)。
//
// F05:record 与 read 路径严格同包;测试通过 squad/executor_result_contract_test.go
// 覆盖正向与逆向条件。
func (d *flowDriver) recordStepTerminal(st *RunState, step StepSpec, result *ExecResult) {
if st == nil || st.Meta.WorkDir == "" {
return
}
if step.ID == "" {
return
}
t := stepTerminalRecord{Terminal: "completed", At: time.Now(), StepID: step.ID, AttemptID: step.AttemptID}
if result != nil {
t.SideEffects = string(result.SideEffects)
t.Canceled = result.Canceled
t.Aborted = result.Aborted
}
st.mu.Lock()
defer st.mu.Unlock()
terminals := readStepTerminalsLocked(st)
terminals[step.ID] = t
data, err := json.Marshal(terminals)
if err != nil {
return
}
_ = os.WriteFile(filepath.Join(st.Meta.WorkDir, "step_terminals.json"), data, 0o644)
}
// readStepTerminalsLocked 读取已落盘的 step_terminals.json(调用方须持锁或私有化)。
// 失败(含文件不存在)返回空 map。
func readStepTerminalsLocked(st *RunState) map[string]stepTerminalRecord {
out := map[string]stepTerminalRecord{}
if st == nil || st.Meta.WorkDir == "" {
return out
}
data, err := os.ReadFile(filepath.Join(st.Meta.WorkDir, "step_terminals.json"))
if err != nil || len(data) == 0 {
return out
}
var stored map[string]stepTerminalRecord
if err := json.Unmarshal(data, &stored); err != nil {
return out
}
for k, v := range stored {
out[k] = v
}
return out
}
// loadStepTerminalsForWorkDir 公开暴露的 record 读取(recovery 使用)。
func loadStepTerminalsForWorkDir(wsDir string) map[string]stepTerminalRecord {
if wsDir == "" {
return map[string]stepTerminalRecord{}
}
data, err := os.ReadFile(filepath.Join(wsDir, "step_terminals.json"))
if err != nil || len(data) == 0 {
return map[string]stepTerminalRecord{}
}
var stored map[string]stepTerminalRecord
if err := json.Unmarshal(data, &stored); err != nil {
return map[string]stepTerminalRecord{}
}
return stored
}
type stepTerminalRecord struct {
StepID string `json:"step_id"`
AttemptID string `json:"attempt_id"`
Terminal string `json:"terminal"` // completed (唯一允许值;failure 走 step.Status)
SideEffects string `json:"side_effects,omitempty"`
Canceled bool `json:"canceled,omitempty"`
Aborted bool `json:"aborted,omitempty"`
At time.Time `json:"at"`
}
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
}