// scheduler.go 实现任务分解的并行调度器(autonomy roadmap §10)。Tick 拓扑式地 // 选取"就绪"(未被阻塞、open、叶子、无活跃 session)的子任务,并把相互独立的若干个 // 扇出到独立 worktree 上的并行 ACP session。调度器是服务端组件,以特权 owner 身份 // 调用 acp.SessionService.Create,因而不受 checkNotSystemToken 限制。 // // 调度器无状态、幂等:ListReadyLeafSubtasks 已排除有活跃 session 的 issue,故重复 tick // 不会重复派单。并发硬上限由 acp 层 MaxActiveGlobal/MaxActivePerUser 兜底(命中时 // CodeAcpSessionQuotaExceeded 当作软失败,下一 tick 重试);软上限由 MaxConcurrentPerProject // 在调度器内预检。把 Scheduler.Tick 注册为 jobs RunnerFn(单实例、单 tick),即可跨重启存活。 package orchestrator import ( "context" "errors" "log/slog" "github.com/google/uuid" "github.com/yan1h/agent-coding-workflow/internal/infra/errs" "github.com/yan1h/agent-coding-workflow/internal/project" ) // ReadyTaskSource 是调度器读取就绪子任务的窄接口(app 层用 project.Repository 适配)。 type ReadyTaskSource interface { // ListSchedulableProjects 返回当前至少有一个就绪子任务的 project id(预筛)。 ListSchedulableProjects(ctx context.Context) ([]uuid.UUID, error) // ListReadyLeafSubtasks 返回 project 下就绪叶子子任务(priority DESC, number ASC),最多 limit 条。 ListReadyLeafSubtasks(ctx context.Context, projectID uuid.UUID, limit int) ([]*project.Issue, error) // CountActiveSessionsForProject 返回 project 内活跃 acp_session 数(软并发预检用)。 CountActiveSessionsForProject(ctx context.Context, projectID uuid.UUID) (int, error) } // SessionStarter 为某 issue 启动一个 ACP session(包装 acp.SessionService.Create)。 // 实现以特权身份(项目 owner / 系统账户)调用,使 worktree Acquire 鉴权通过。 type SessionStarter interface { // StartForIssue 为 issue 创建一个绑定 issue/-N worktree 的 session,返回 session id。 StartForIssue(ctx context.Context, iss *project.Issue) (uuid.UUID, error) } // SchedulerNotifier 是调度器投递 ready/dispatch/quota 通知的窄接口(app 用 notify.Dispatcher 适配)。 type SchedulerNotifier interface { NotifyDispatched(ctx context.Context, iss *project.Issue, sessionID uuid.UUID) NotifyQuotaBlocked(ctx context.Context, iss *project.Issue) } // SchedulerDeps 是调度器依赖集合。 type SchedulerDeps struct { Projects ReadyTaskSource Sessions SessionStarter Notify SchedulerNotifier Log *slog.Logger // MaxFanoutPerTick 是每个 project 每 tick 最多派发的 session 数(<=0 时默认 8)。 MaxFanoutPerTick int // MaxConcurrentPerProject 是 project 内活跃 session 软上限(<=0 时不预检,仅靠 acp 层硬上限)。 MaxConcurrentPerProject int } // Scheduler 是任务分解并行调度器。单一 Tick 方法便于解耦其宿主(jobs RunnerFn 或 // 未来的 pipeline host)。 type Scheduler interface { // Tick 执行一轮调度,返回本轮派发的 session 数。单个 issue 派发失败不致整轮失败。 Tick(ctx context.Context) (dispatched int, err error) // Run 是 Tick 的 jobs.RunnerFunc 适配(func(ctx)):记录日志、吞掉错误。 Run(ctx context.Context) } type scheduler struct { projects ReadyTaskSource sessions SessionStarter notify SchedulerNotifier log *slog.Logger maxFanoutPerTick int maxConcurrentPerProject int } // NewScheduler 构造调度器。 func NewScheduler(d SchedulerDeps) Scheduler { log := d.Log if log == nil { log = slog.Default() } fanout := d.MaxFanoutPerTick if fanout <= 0 { fanout = 8 } return &scheduler{ projects: d.Projects, sessions: d.Sessions, notify: d.Notify, log: log, maxFanoutPerTick: fanout, maxConcurrentPerProject: d.MaxConcurrentPerProject, } } var _ Scheduler = (*scheduler)(nil) // Run 把 Tick 适配为 jobs.RunnerFunc 签名(func(ctx)):记录日志、吞掉错误。 func (s *scheduler) Run(ctx context.Context) { n, err := s.Tick(ctx) if err != nil { s.log.Warn("orchestrator.scheduler.tick_failed", "err", err.Error()) return } if n > 0 { s.log.Info("orchestrator.scheduler.tick", "dispatched", n) } } func (s *scheduler) Tick(ctx context.Context) (int, error) { projects, err := s.projects.ListSchedulableProjects(ctx) if err != nil { return 0, err } total := 0 for _, projID := range projects { n, perr := s.tickProject(ctx, projID) total += n if perr != nil { // 单个 project 失败不致整轮失败:记录并继续下一个 project。 s.log.Warn("orchestrator.scheduler.project_failed", "project_id", projID.String(), "err", perr.Error()) continue } } return total, nil } // tickProject 为单个 project 派发就绪子任务。受 MaxFanoutPerTick 与(可选) // MaxConcurrentPerProject 软上限约束。命中 acp 配额(CodeAcpSessionQuotaExceeded)时 // 停止本 project(软失败,下一 tick 重试),不返回 error。 func (s *scheduler) tickProject(ctx context.Context, projectID uuid.UUID) (int, error) { limit := s.maxFanoutPerTick // 软并发预检:若已达 project 内活跃上限,本 tick 不派发。 if s.maxConcurrentPerProject > 0 { active, err := s.projects.CountActiveSessionsForProject(ctx, projectID) if err != nil { return 0, err } budget := s.maxConcurrentPerProject - active if budget <= 0 { return 0, nil } if budget < limit { limit = budget } } ready, err := s.projects.ListReadyLeafSubtasks(ctx, projectID, limit) if err != nil { return 0, err } dispatched := 0 for _, iss := range ready { sessionID, serr := s.sessions.StartForIssue(ctx, iss) if serr != nil { if isQuotaExceeded(serr) { // 软配额:停止本 project 的派发,下一 tick 重试。通知关注者。 if s.notify != nil { s.notify.NotifyQuotaBlocked(ctx, iss) } return dispatched, nil } // 其它失败:记录并跳过该 issue,继续其余就绪 issue。 s.log.Warn("orchestrator.scheduler.start_failed", "issue_id", iss.ID.String(), "number", iss.Number, "err", serr.Error()) continue } dispatched++ if s.notify != nil { s.notify.NotifyDispatched(ctx, iss, sessionID) } } return dispatched, nil } // isQuotaExceeded 报告 err 是否为 acp 会话配额超限(软失败语义)。 func isQuotaExceeded(err error) bool { var ae *errs.AppError if errors.As(err, &ae) { return ae.Code == errs.CodeAcpSessionQuotaExceeded } return false }