Files
agentic-coding-workflow/internal/orchestrator/step_handler.go
T
2026-06-22 08:55:57 +08:00

342 lines
12 KiB
Go

// step_handler.go 把 orchestrator.step 接入 jobs:每个 step 由一个 orchestrator.step
// job 驱动,因而能跨重启恢复并复用既有 backoff/dead-letter 机制。
package orchestrator
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"time"
"github.com/google/uuid"
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
"github.com/yan1h/agent-coding-workflow/internal/jobs"
"github.com/yan1h/agent-coding-workflow/internal/project"
)
// JobTypeOrchestratorStep 是驱动单个编排 step 的 job 类型。
const JobTypeOrchestratorStep jobs.JobType = "orchestrator.step"
// stepPayload 是 orchestrator.step job 的 payload。
type stepPayload struct {
RunID uuid.UUID `json:"run_id"`
StepID uuid.UUID `json:"step_id"`
}
// StepHandlerDeps 是 StepHandler 的依赖集合。
type StepHandlerDeps struct {
Repo Repository
Runner *runner
Gate PhaseGate
PM PMAccess
Enqueue StepEnqueuer
DefaultAgentKind map[project.Phase]uuid.UUID
Config Config
Audit AuditRecorder
Log *slog.Logger
}
// StepHandler 实现 jobs.Handler,驱动一个阶段回合并推进/回环状态机。
type StepHandler struct {
repo Repository
runner *runner
gate PhaseGate
pm PMAccess
enqueue StepEnqueuer
defaults map[project.Phase]uuid.UUID
cfg Config
audit AuditRecorder
log *slog.Logger
}
// NewStepHandler 构造 StepHandler。
func NewStepHandler(d StepHandlerDeps) *StepHandler {
log := d.Log
if log == nil {
log = slog.Default()
}
return &StepHandler{
repo: d.Repo, runner: d.Runner, gate: d.Gate, pm: d.PM,
enqueue: d.Enqueue, defaults: d.DefaultAgentKind, cfg: d.Config,
audit: d.Audit, log: log,
}
}
// Type 返回 job 类型。
func (h *StepHandler) Type() jobs.JobType { return JobTypeOrchestratorStep }
// Handle 驱动一个 step:load step+run;run 非活跃 → no-op;spawn-or-resume 跑回合;
// 评估网关;通过 → ChangePhase + 入队下一 step(或 done → run 成功);否则递增 attempt
// 并返回 error(attempt 耗尽 → jobs.Permanent → dead-letter)。
//
// 幂等:先按 step.status 短路,避免重复 leased 时重复 spawn(worker 已有 Complete fence)。
func (h *StepHandler) Handle(ctx context.Context, job jobs.Job) error {
var p stepPayload
if err := json.Unmarshal(job.Payload, &p); err != nil {
return jobs.Permanent(errs.Wrap(err, errs.CodeInvalidInput, "orchestrator.step: bad payload"))
}
step, err := h.repo.GetStep(ctx, p.StepID)
if err != nil {
// step 不存在:永久失败(无法恢复)。
return jobs.Permanent(err)
}
// 已到终态:no-op(重复投递 / 重启重入)。
switch step.Status {
case StepSucceeded, StepFailed, StepDead:
return nil
}
run, err := h.repo.GetRun(ctx, step.RunID)
if err != nil {
return jobs.Permanent(err)
}
// run 已暂停/取消/终态:不驱动,完成 job(释放 worker)。
if !run.Status.IsActive() {
h.log.Info("orchestrator.step.skip_inactive_run",
"run_id", run.ID, "step_id", step.ID, "run_status", run.Status)
return nil
}
// run.status pending → running(首个 step 起跑)。
if run.Status == RunPending {
if _, uerr := h.repo.UpdateRunStatus(ctx, run.ID, RunRunning, nil); uerr != nil {
h.log.Warn("orchestrator.run.mark_running_failed", "run_id", run.ID, "err", uerr.Error())
}
}
// 解析需求(用于 prompt 上下文 + 阶段绑定 + ChangePhase)。
req, err := h.pm.GetRequirementByID(ctx, run.RequirementID)
if err != nil {
return jobs.Permanent(err)
}
proj, err := h.pm.GetProjectByID(ctx, run.ProjectID)
if err != nil {
return jobs.Permanent(err)
}
// step.prompt 为空时(首建只填了占位)补建 prompt。通常 service 建 step 时已填。
if step.Prompt == "" {
artifacts, _ := h.pm.ListLatestArtifacts(ctx, run.RequirementID)
override := run.Config.PromptOverrides[step.Phase]
step.Prompt = BuildPrompt(step.Phase, req, artifacts, override)
}
if step.Status == StepAwaitingGate && step.GateResult != nil && step.GateResult.Pass {
return h.advancePassedStep(ctx, run, step, req, proj, *step.GateResult, "")
}
// 标记 spawning(关联本 job)。
jobID := job.ID
step.Status = StepSpawning
step.JobID = &jobID
if updated, uerr := h.repo.UpdateStepStatus(ctx, step); uerr == nil {
step = updated
}
// 驱动回合(spawn-or-resume + SendPromptAndWait)。
res, terr := h.runner.driveTurn(ctx, run, step, req.Number)
if terr != nil {
return h.failStep(ctx, run, step, terr)
}
// 记录 session + stopReason,进入 awaiting_gate。
step.ACPSessionID = &res.SessionID
stop := res.StopReason
step.StopReason = &stop
step.Status = StepAwaitingGate
if updated, uerr := h.repo.UpdateStepStatus(ctx, step); uerr == nil {
step = updated
}
// 评估网关。
gate, gerr := h.gate.Evaluate(ctx, run, step, res.StopReason)
if gerr != nil {
return h.failStep(ctx, run, step, gerr)
}
step.GateResult = &gate
if !gate.Pass {
return h.failStepGate(ctx, run, step, gate)
}
// 网关通过后先停在 awaiting_gate;只有 project.ChangePhase 与 run phase 都写成功后,
// 才能把 step 标为 succeeded 并入队下一阶段,避免绕过 project 的 entry gate。
step.Status = StepAwaitingGate
step.GateResult = &gate
step.LastError = nil
if updated, uerr := h.repo.UpdateStepStatus(ctx, step); uerr == nil {
step = updated
}
return h.advancePassedStep(ctx, run, step, req, proj, gate, res.StopReason)
}
func (h *StepHandler) advancePassedStep(ctx context.Context, run *Run, step *Step, req *project.Requirement, proj *project.Project, gate GateResult, stopReason string) error {
// 推进阶段。
next, terminal := NextPhase(step.Phase, gate)
if terminal {
step.Status = StepSucceeded
step.LastError = nil
if updated, uerr := h.repo.UpdateStepStatus(ctx, step); uerr == nil {
step = updated
}
h.recordAudit(ctx, run, "orchestrator.step.advance", step.ID.String(), map[string]any{
"phase": string(step.Phase),
"stop_reason": stopReason,
})
// 整条 run 完成。
if _, uerr := h.repo.UpdateRunStatus(ctx, run.ID, RunSucceeded, nil); uerr != nil {
h.log.Warn("orchestrator.run.mark_succeeded_failed", "run_id", run.ID, "err", uerr.Error())
}
h.recordAudit(ctx, run, "orchestrator.run.succeeded", run.ID.String(), nil)
return nil
}
// 写需求阶段(run.owner 身份)。失败必须阻塞编排推进;project.ChangePhase 是
// 更靠近领域状态的准入网关,不能被 orchestrator.run.current_phase 越过。
if cerr := h.pm.ChangeRequirementPhase(ctx, run.OwnerID, false, proj.Slug, req.Number, next); cerr != nil {
h.log.Warn("orchestrator.change_phase_failed",
"run_id", run.ID, "to", next, "err", cerr.Error())
return h.failPhaseAdvance(ctx, run, step, next, cerr)
}
if _, uerr := h.repo.UpdateRunPhase(ctx, run.ID, next); uerr != nil {
h.log.Warn("orchestrator.run.update_phase_failed", "run_id", run.ID, "err", uerr.Error())
return h.failPhaseAdvance(ctx, run, step, next, uerr)
}
step.Status = StepSucceeded
step.LastError = nil
if updated, uerr := h.repo.UpdateStepStatus(ctx, step); uerr == nil {
step = updated
}
h.recordAudit(ctx, run, "orchestrator.step.advance", step.ID.String(), map[string]any{
"phase": string(step.Phase),
"stop_reason": stopReason,
})
// 建并入队下一阶段 step。
if _, eerr := h.enqueueNextStep(ctx, run, next); eerr != nil {
return eerr
}
return nil
}
// enqueueNextStep 为 run 在 phase 上建一个新 step 并入队其 job。复用 service 的逻辑。
func (h *StepHandler) enqueueNextStep(ctx context.Context, run *Run, phase project.Phase) (*Step, error) {
step, err := buildPhaseStep(ctx, h.repo, h.pm, run, phase, nil, 0, h.defaults)
if err != nil {
// 无法为下一阶段建 step(如缺 agent kind):标 run 失败。
msg := err.Error()
if _, uerr := h.repo.UpdateRunStatus(ctx, run.ID, RunFailed, &msg); uerr != nil {
h.log.Warn("orchestrator.run.mark_failed", "run_id", run.ID, "err", uerr.Error())
}
return nil, jobs.Permanent(err)
}
if eerr := h.enqueue.EnqueueStep(ctx, run.ID, step.ID, time.Now().UTC()); eerr != nil {
return nil, eerr
}
return step, nil
}
// failStep 处理回合驱动/网关评估的错误:递增 attempt,返回可重试或 Permanent error。
func (h *StepHandler) failStep(ctx context.Context, run *Run, step *Step, cause error) error {
msg := cause.Error()
step.Status = StepRunning // 保持非终态,等待重试或 dead-letter 落定
step.LastError = &msg
_, _ = h.repo.UpdateStepStatus(ctx, step)
updated, ierr := h.repo.IncrementStepAttempt(ctx, step.ID)
if ierr == nil {
step = updated
}
return h.retryOrDead(ctx, run, step, cause, false)
}
// failStepGate 处理网关未通过:Retry=false → 立即 Permanent;否则递增 attempt 后判断。
func (h *StepHandler) failStepGate(ctx context.Context, run *Run, step *Step, gate GateResult) error {
reason := gate.Reason
step.LastError = &reason
step.Status = StepRunning
_, _ = h.repo.UpdateStepStatus(ctx, step)
cause := errs.New(errs.CodePhaseGateFailed, gate.Reason)
if !gate.Retry {
return h.markDead(ctx, run, step, cause)
}
updated, ierr := h.repo.IncrementStepAttempt(ctx, step.ID)
if ierr == nil {
step = updated
}
return h.retryOrDead(ctx, run, step, cause, true)
}
func (h *StepHandler) failPhaseAdvance(ctx context.Context, run *Run, step *Step, next project.Phase, cause error) error {
msg := cause.Error()
step.Status = StepAwaitingGate
step.LastError = &msg
_, _ = h.repo.UpdateStepStatus(ctx, step)
if ae, ok := errs.As(cause); ok && ae.Code == errs.CodePhaseGateFailed {
if _, uerr := h.repo.UpdateRunStatus(ctx, run.ID, RunPaused, &msg); uerr != nil {
h.log.Warn("orchestrator.run.mark_paused_failed", "run_id", run.ID, "err", uerr.Error())
}
h.recordAudit(ctx, run, "orchestrator.step.phase_blocked", step.ID.String(), map[string]any{
"phase": string(step.Phase),
"next": string(next),
"err": msg,
})
return nil
}
updated, ierr := h.repo.IncrementStepAttempt(ctx, step.ID)
if ierr == nil {
step = updated
}
return h.retryOrDead(ctx, run, step, cause, false)
}
// retryOrDead 根据 attempt 与 MaxAttemptsPerPhase 决定可重试还是 dead-letter。
func (h *StepHandler) retryOrDead(ctx context.Context, run *Run, step *Step, cause error, gateFail bool) error {
maxAttempts := run.Config.MaxAttemptsPerPhase
if maxAttempts <= 0 {
maxAttempts = h.cfg.MaxAttemptsPerPhase
}
if maxAttempts <= 0 {
maxAttempts = 3
}
if step.Attempt >= maxAttempts {
return h.markDead(ctx, run, step, cause)
}
h.recordAudit(ctx, run, "orchestrator.step.retry", step.ID.String(), map[string]any{
"phase": string(step.Phase),
"attempt": step.Attempt,
"err": cause.Error(),
"gate": gateFail,
})
// 返回非 Permanent error:jobs worker 走 backoff 重试,重新驱动本 step。
return fmt.Errorf("orchestrator.step retry (attempt %d/%d): %w", step.Attempt, maxAttempts, cause)
}
// markDead 把 step 标 dead、run 标 failed,返回 jobs.Permanent(dead-letter)。
func (h *StepHandler) markDead(ctx context.Context, run *Run, step *Step, cause error) error {
msg := cause.Error()
step.Status = StepDead
step.LastError = &msg
_, _ = h.repo.UpdateStepStatus(ctx, step)
if _, uerr := h.repo.UpdateRunStatus(ctx, run.ID, RunFailed, &msg); uerr != nil {
h.log.Warn("orchestrator.run.mark_failed", "run_id", run.ID, "err", uerr.Error())
}
h.recordAudit(ctx, run, "orchestrator.step.dead_letter", step.ID.String(), map[string]any{
"phase": string(step.Phase),
"err": msg,
})
return jobs.Permanent(cause)
}
func (h *StepHandler) recordAudit(ctx context.Context, run *Run, action, targetID string, meta map[string]any) {
if h.audit == nil {
return
}
uid := run.OwnerID
h.audit.Record(ctx, uid, action, targetID, meta)
}