This commit is contained in:
2026-06-22 08:55:57 +08:00
parent dbb87823e8
commit 6ade6e8fa9
325 changed files with 41131 additions and 855 deletions
+500
View File
@@ -0,0 +1,500 @@
// 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<MaxDepth 与 active children<FanOutLimit(避免并发竞态突破上限),插入子 step,
// 事务提交后入队其 job。
func (s *service) RequestSubtask(ctx context.Context, parentACPSessionID uuid.UUID, in SubtaskInput) (*Step, error) {
parent, err := s.repo.GetStepBySession(ctx, parentACPSessionID)
if err != nil {
return nil, errs.New(errs.CodeForbidden, "calling session is not an orchestrator step")
}
run, err := s.repo.GetRun(ctx, parent.RunID)
if err != nil {
return nil, err
}
if !run.Status.IsActive() {
return nil, errs.New(errs.CodeConflict, "run is not active")
}
phase := in.Phase
if phase == "" {
phase = parent.Phase
}
if !phase.IsValid() {
return nil, errs.New(errs.CodeInvalidInput, "invalid subtask phase")
}
maxDepth := run.Config.MaxDepth
if maxDepth <= 0 {
maxDepth = s.cfg.MaxDepth
}
fanOut := run.Config.FanOutLimit
if fanOut <= 0 {
fanOut = s.cfg.FanOutLimit
}
childDepth := parent.Depth + 1
if maxDepth > 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
}