You've already forked agentic-coding-workflow
342 lines
12 KiB
Go
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)
|
|
}
|