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