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

185 lines
6.6 KiB
Go

// 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/<slug>-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
}