// service.go 实现 orchestrator.Service:StartRun / GetRun / ListRuns / Pause / // Resume / Cancel / RequestSubtask / EnqueueStepRedrive。step 的实际驱动在 // step_handler.go(jobs worker 内执行)。 package orchestrator import ( "context" "encoding/json" "log/slog" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5" "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" ) // JobEnqueuer 是 service 入队 orchestrator.step job 所需的窄接口(jobs.Repository 满足)。 type JobEnqueuer interface { Enqueue(ctx context.Context, typ jobs.JobType, payload json.RawMessage, scheduledAt time.Time, maxAttempts int32) (*jobs.Job, error) } // ProjectACL 是 StartRun 校验 owner 对项目写权限的窄接口(app 层用 project 的 // owner+admin ACL 适配)。 type ProjectACL interface { CanRead(ctx context.Context, userID uuid.UUID, isAdmin bool, projectID uuid.UUID) (bool, error) CanWrite(ctx context.Context, userID uuid.UUID, isAdmin bool, projectID uuid.UUID) (bool, error) } // ServiceDeps 是 service 的依赖集合。 type ServiceDeps struct { Repo Repository PM PMAccess ACL ProjectACL Jobs JobEnqueuer Sessions sessionTerminator Defaults map[project.Phase]uuid.UUID Config Config Audit AuditRecorder Log *slog.Logger } // sessionTerminator 终止某 acp session(Pause/Cancel 用)。acp.SessionService 经 adapter 适配。 type sessionTerminator interface { TerminateSession(ctx context.Context, ownerID uuid.UUID, isAdmin bool, sessionID uuid.UUID) error } type service struct { repo Repository pm PMAccess acl ProjectACL jobs JobEnqueuer sessions sessionTerminator defaults map[project.Phase]uuid.UUID cfg Config audit AuditRecorder log *slog.Logger } // NewService 构造编排器 Service。 func NewService(d ServiceDeps) Service { log := d.Log if log == nil { log = slog.Default() } return &service{ repo: d.Repo, pm: d.PM, acl: d.ACL, jobs: d.Jobs, sessions: d.Sessions, defaults: d.Defaults, cfg: d.Config, audit: d.Audit, log: log, } } var _ Service = (*service)(nil) // EnqueueStep 实现 stepEnqueuer:入队一个 orchestrator.step job。 func (s *service) EnqueueStep(ctx context.Context, runID, stepID uuid.UUID, scheduledAt time.Time) error { payload, err := json.Marshal(stepPayload{RunID: runID, StepID: stepID}) if err != nil { return errs.Wrap(err, errs.CodeInternal, "marshal step payload") } // maxAttempts: jobs 层的尝试计数仅用于 reaper/worker 调度;编排器自身以 step.attempt // + MaxAttemptsPerPhase 控制重试上限。给一个较大的 jobs maxAttempts,让 worker 在 // 编排器返回非 Permanent error 时继续重试,直到编排器返回 Permanent。 maxAttempts := int32(s.effectiveMaxAttempts() + 2) if _, err := s.jobs.Enqueue(ctx, JobTypeOrchestratorStep, payload, scheduledAt, maxAttempts); err != nil { return err } return nil } func (s *service) effectiveMaxAttempts() int { if s.cfg.MaxAttemptsPerPhase > 0 { return s.cfg.MaxAttemptsPerPhase } return 3 } // StartRun 启动一次编排运行:解析项目/需求 + ACL 校验 + 在一个事务内插入 run + 第一个 // step,事务提交后入队第一个 orchestrator.step job。每需求只允许一个活跃 run(DB 唯一 // 部分索引兜底,命中翻译为 409)。 func (s *service) StartRun(ctx context.Context, c Caller, in StartRunInput) (*Run, error) { proj, err := s.pm.GetProjectBySlug(ctx, in.ProjectSlug) if err != nil { return nil, err } // 写权限校验:owner 须对项目可写(不依赖 admin 放行)。 if s.acl != nil { ok, aerr := s.acl.CanWrite(ctx, c.UserID, c.IsAdmin, proj.ID) if aerr != nil { return nil, aerr } if !ok { return nil, errs.New(errs.CodeForbidden, "no write access to project") } } req, err := s.pm.GetRequirementByNumber(ctx, proj.ID, in.RequirementNumber) if err != nil { return nil, err } if req.WorkspaceID == nil { return nil, errs.New(errs.CodeInvalidInput, "requirement has no workspace; link a workspace before starting a run") } if req.Status == project.StatusClosed { return nil, errs.New(errs.CodeRequirementClosed, "requirement is closed") } startPhase := in.StartPhase if startPhase == "" { startPhase = req.Phase } if !startPhase.IsValid() { return nil, errs.New(errs.CodeInvalidInput, "invalid start phase") } // 规范化 config 默认值。 cfg := in.Config if cfg.MaxAttemptsPerPhase <= 0 { cfg.MaxAttemptsPerPhase = s.effectiveMaxAttempts() } if cfg.MaxDepth <= 0 { cfg.MaxDepth = s.cfg.MaxDepth } if cfg.FanOutLimit <= 0 { cfg.FanOutLimit = s.cfg.FanOutLimit } run := &Run{ ID: uuid.New(), ProjectID: proj.ID, RequirementID: req.ID, WorkspaceID: *req.WorkspaceID, OwnerID: proj.OwnerID, Status: RunPending, CurrentPhase: startPhase, Config: cfg, } var ( outRun *Run outStep *Step ) txErr := s.repo.InTx(ctx, func(txCtx context.Context, tx pgx.Tx) error { txRepo := s.repo.WithTx(tx) inserted, ierr := txRepo.InsertRun(txCtx, run) if ierr != nil { return ierr } outRun = inserted step, serr := buildPhaseStep(txCtx, txRepo, s.pm, inserted, startPhase, nil, 0, s.defaults) if serr != nil { return serr } outStep = step return nil }) if txErr != nil { if IsUniqueViolation(txErr) { return nil, errs.New(errs.CodeConflict, "an active orchestrator run already exists for this requirement") } return nil, txErr } // 事务已提交:入队第一个 step job。 if eerr := s.EnqueueStep(ctx, outRun.ID, outStep.ID, time.Now().UTC()); eerr != nil { // 入队失败:标 run 失败(step 已落库但永不会被驱动)。 msg := eerr.Error() _, _ = s.repo.UpdateRunStatus(ctx, outRun.ID, RunFailed, &msg) return nil, eerr } s.recordAudit(ctx, c.UserID, "orchestrator.run.start", outRun.ID.String(), map[string]any{ "project_slug": in.ProjectSlug, "requirement": in.RequirementNumber, "start_phase": string(startPhase), "first_step_id": outStep.ID.String(), "execution_owner_id": outRun.OwnerID.String(), }) return outRun, nil } func (s *service) GetRun(ctx context.Context, c Caller, id uuid.UUID) (*Run, error) { run, err := s.repo.GetRun(ctx, id) if err != nil { return nil, err } if !s.canReadRun(ctx, c, run) { return nil, errs.New(errs.CodeNotFound, "orchestrator run not found") } return run, nil } func (s *service) ListRuns(ctx context.Context, c Caller, f RunFilter) ([]*Run, error) { runs, err := s.repo.ListRuns(ctx, f) if err != nil { return nil, err } if c.IsAdmin { return runs, nil } out := make([]*Run, 0, len(runs)) for _, r := range runs { if s.canReadRun(ctx, c, r) { out = append(out, r) } } return out, nil } // Pause 把活跃 run 置 paused 并终止其当前活跃 session(停止驱动;已入队 step job 在 // Handle 时见 run 非活跃即 no-op)。 func (s *service) Pause(ctx context.Context, c Caller, id uuid.UUID) error { run, err := s.GetRun(ctx, c, id) if err != nil { return err } if !run.Status.IsActive() { return errs.New(errs.CodeConflict, "run is not active") } if _, uerr := s.repo.UpdateRunStatus(ctx, run.ID, RunPaused, nil); uerr != nil { return uerr } s.terminateActiveSessions(ctx, run) s.recordAudit(ctx, c.UserID, "orchestrator.run.pause", run.ID.String(), nil) return nil } // Resume 把 paused run 置回 running 并重新入队当前阶段的一个 step(让驱动继续)。 func (s *service) Resume(ctx context.Context, c Caller, id uuid.UUID) error { run, err := s.GetRun(ctx, c, id) if err != nil { return err } if run.Status != RunPaused { return errs.New(errs.CodeConflict, "run is not paused") } if _, uerr := s.repo.UpdateRunStatus(ctx, run.ID, RunRunning, nil); uerr != nil { return uerr } // 找当前阶段最近一个非终态 step 重新入队;若没有则新建一个。 step, serr := s.latestResumableStep(ctx, run) if serr != nil { return serr } if step == nil { built, berr := buildPhaseStep(ctx, s.repo, s.pm, run, run.CurrentPhase, nil, 0, s.defaults) if berr != nil { return berr } step = built } if eerr := s.EnqueueStep(ctx, run.ID, step.ID, time.Now().UTC()); eerr != nil { return eerr } s.recordAudit(ctx, c.UserID, "orchestrator.run.resume", run.ID.String(), nil) return nil } // Cancel 把 run 置 canceled 并终止其活跃 session。 func (s *service) Cancel(ctx context.Context, c Caller, id uuid.UUID) error { run, err := s.GetRun(ctx, c, id) if err != nil { return err } if run.Status.IsTerminal() { return errs.New(errs.CodeConflict, "run already terminal") } if _, uerr := s.repo.UpdateRunStatus(ctx, run.ID, RunCanceled, nil); uerr != nil { return uerr } s.terminateActiveSessions(ctx, run) s.recordAudit(ctx, c.UserID, "orchestrator.run.cancel", run.ID.String(), nil) return nil } // RequestSubtask 让运行中的 agent 为其当前 step 派生子 step。在一个事务内校验 // depth 0 && childDepth > maxDepth { return nil, errs.New(errs.CodeForbidden, "subtask depth limit reached") } akID := uuid.Nil if in.AgentKindID != nil { akID = *in.AgentKindID } var outStep *Step txErr := s.repo.InTx(ctx, func(txCtx context.Context, tx pgx.Tx) error { txRepo := s.repo.WithTx(tx) // 在同一事务内计活跃子 step,避免并发突破 fan-out。 n, cerr := txRepo.CountActiveChildSteps(txCtx, parent.ID) if cerr != nil { return cerr } if fanOut > 0 && int(n) >= fanOut { return errs.New(errs.CodeForbidden, "subtask fan-out limit reached") } // 解析 agent-kind:显式给定优先,否则走 planner(config/defaults)。 resolvedAK := akID if resolvedAK == uuid.Nil { id, perr := PhaseAgentKind(run.Config, phase, s.defaults) if perr != nil { return perr } resolvedAK = id } req, rerr := s.pm.GetRequirementByID(txCtx, run.RequirementID) if rerr != nil { return rerr } artifacts, _ := s.pm.ListLatestArtifacts(txCtx, run.RequirementID) prompt := in.Prompt if prompt == "" { prompt = BuildPrompt(phase, req, artifacts, run.Config.PromptOverrides[phase]) } parentID := parent.ID step := &Step{ ID: uuid.New(), RunID: run.ID, Phase: phase, Attempt: 1, ParentStepID: &parentID, Depth: childDepth, Status: StepPending, AgentKindID: resolvedAK, Prompt: prompt, } inserted, ierr := txRepo.InsertStep(txCtx, step) if ierr != nil { return ierr } outStep = inserted return nil }) if txErr != nil { return nil, txErr } if eerr := s.EnqueueStep(ctx, run.ID, outStep.ID, time.Now().UTC()); eerr != nil { return nil, eerr } s.recordAudit(ctx, run.OwnerID, "orchestrator.subtask.request", outStep.ID.String(), map[string]any{ "parent_step_id": parent.ID.String(), "phase": string(phase), "depth": childDepth, }) return outStep, nil } // EnqueueStepRedrive 由 acp supervisor onExit 的注入回调间接调用:把崩溃 session 的 // step 重新入队(短延迟,给 worktree 释放留窗口)。 func (s *service) EnqueueStepRedrive(ctx context.Context, stepID uuid.UUID, reason string) { step, err := s.repo.GetStep(ctx, stepID) if err != nil { s.log.Warn("orchestrator.redrive.step_missing", "step_id", stepID, "err", err.Error()) return } switch step.Status { case StepSucceeded, StepFailed, StepDead: return // 已终态:无需再驱动 case StepSpawning, StepRunning, StepAwaitingGate: // 仍有活跃 job 在驱动本 step:其 driveTurn 会因 session 崩溃触发 relayCtx 取消而 // 返回错误 → 走 worker 重试(driveTurn 是 spawn-or-resume,会在同一 worktree 续跑); // worker 进程整体崩溃的情形由 job_reaper 重新租约兜底。此处若再入队会让同一 step // 产生两条 job、双重驱动(两个 agent 会话)。故跳过,恢复只走活跃 job 这一条路径。 s.log.Info("orchestrator.redrive.skip_active", "step_id", stepID, "status", step.Status, "reason", reason) return } run, err := s.repo.GetRun(ctx, step.RunID) if err != nil || !run.Status.IsActive() { return // run 不活跃:不再驱动 } // 短延迟入队,让 onExit 的 worktree 释放先落定。 if eerr := s.EnqueueStep(ctx, run.ID, step.ID, time.Now().UTC().Add(2*time.Second)); eerr != nil { s.log.Warn("orchestrator.redrive.enqueue_failed", "step_id", stepID, "err", eerr.Error()) return } s.recordAudit(ctx, run.OwnerID, "orchestrator.step.redrive", step.ID.String(), map[string]any{ "reason": reason, }) } // ===== helpers ===== // latestResumableStep 返回当前阶段最近一个非终态 step(用于 Resume 重新入队)。 func (s *service) latestResumableStep(ctx context.Context, run *Run) (*Step, error) { steps, err := s.repo.ListStepsByRun(ctx, run.ID) if err != nil { return nil, err } var found *Step for _, st := range steps { if st.Phase != run.CurrentPhase { continue } switch st.Status { case StepSucceeded, StepFailed, StepDead: continue } found = st // ListStepsByRun 按 created_at 升序:取最后一个匹配 } return found, nil } // terminateActiveSessions 终止 run 下当前活跃 step 的 session(best-effort)。 func (s *service) terminateActiveSessions(ctx context.Context, run *Run) { if s.sessions == nil { return } steps, err := s.repo.ListStepsByRun(ctx, run.ID) if err != nil { return } for _, st := range steps { if st.ACPSessionID == nil { continue } switch st.Status { case StepSpawning, StepRunning, StepAwaitingGate: if terr := s.sessions.TerminateSession(ctx, run.OwnerID, false, *st.ACPSessionID); terr != nil { s.log.Warn("orchestrator.terminate_session_failed", "session_id", *st.ACPSessionID, "err", terr.Error()) } } } } func (s *service) recordAudit(ctx context.Context, userID uuid.UUID, action, targetID string, meta map[string]any) { if s.audit == nil { return } s.audit.Record(context.WithoutCancel(ctx), userID, action, targetID, meta) } func (s *service) canReadRun(ctx context.Context, c Caller, run *Run) bool { if c.IsAdmin || run.OwnerID == c.UserID { return true } if s.acl == nil { return false } ok, err := s.acl.CanRead(ctx, c.UserID, c.IsAdmin, run.ProjectID) return err == nil && ok }