package orchestrator import ( "context" "sync" "testing" "github.com/google/uuid" "github.com/stretchr/testify/require" "github.com/yan1h/agent-coding-workflow/internal/infra/errs" "github.com/yan1h/agent-coding-workflow/internal/project" ) // ===== fakeReadySource: orchestrator.ReadyTaskSource ===== type fakeReadySource struct { mu sync.Mutex projects []uuid.UUID ready map[uuid.UUID][]*project.Issue // projectID → ready issues active map[uuid.UUID]int // projectID → active session count listCalls int } func (f *fakeReadySource) ListSchedulableProjects(_ context.Context) ([]uuid.UUID, error) { return f.projects, nil } func (f *fakeReadySource) ListReadyLeafSubtasks(_ context.Context, projectID uuid.UUID, limit int) ([]*project.Issue, error) { f.mu.Lock() defer f.mu.Unlock() f.listCalls++ all := f.ready[projectID] // 模拟 DB:排除已被启动(active)的 issue 由调用方维护;此处仅按 limit 截断。 if limit > 0 && len(all) > limit { return all[:limit], nil } return all, nil } func (f *fakeReadySource) CountActiveSessionsForProject(_ context.Context, projectID uuid.UUID) (int, error) { f.mu.Lock() defer f.mu.Unlock() return f.active[projectID], nil } // ===== fakeSessionStarter: orchestrator.SessionStarter ===== type fakeSessionStarter struct { mu sync.Mutex started []uuid.UUID // issue ids quotaFrom int // 第 quotaFrom 次起返回配额错误(0 表示不触发) calls int failAll bool } func (f *fakeSessionStarter) StartForIssue(_ context.Context, iss *project.Issue) (uuid.UUID, error) { f.mu.Lock() defer f.mu.Unlock() f.calls++ if f.failAll { return uuid.Nil, errs.New(errs.CodeInternal, "boom") } if f.quotaFrom > 0 && f.calls >= f.quotaFrom { return uuid.Nil, errs.New(errs.CodeAcpSessionQuotaExceeded, "quota") } f.started = append(f.started, iss.ID) return uuid.New(), nil } // ===== fakeSchedNotify: orchestrator.SchedulerNotifier ===== type fakeSchedNotify struct { dispatched int quota int } func (f *fakeSchedNotify) NotifyDispatched(_ context.Context, _ *project.Issue, _ uuid.UUID) { f.dispatched++ } func (f *fakeSchedNotify) NotifyQuotaBlocked(_ context.Context, _ *project.Issue) { f.quota++ } func mkReadyIssues(projectID uuid.UUID, n int) []*project.Issue { out := make([]*project.Issue, 0, n) for i := 0; i < n; i++ { out = append(out, &project.Issue{ID: uuid.New(), ProjectID: projectID, Number: i + 1}) } return out } func TestScheduler_DispatchesUpToFanout(t *testing.T) { projID := uuid.New() src := &fakeReadySource{ projects: []uuid.UUID{projID}, ready: map[uuid.UUID][]*project.Issue{projID: mkReadyIssues(projID, 5)}, active: map[uuid.UUID]int{}, } starter := &fakeSessionStarter{} notify := &fakeSchedNotify{} sched := NewScheduler(SchedulerDeps{ Projects: src, Sessions: starter, Notify: notify, MaxFanoutPerTick: 3, }) n, err := sched.Tick(context.Background()) require.NoError(t, err) require.Equal(t, 3, n, "应只派发 min(ready=5, fanout=3)") require.Len(t, starter.started, 3) require.Equal(t, 3, notify.dispatched) } func TestScheduler_QuotaIsSoftFailure(t *testing.T) { projID := uuid.New() src := &fakeReadySource{ projects: []uuid.UUID{projID}, ready: map[uuid.UUID][]*project.Issue{projID: mkReadyIssues(projID, 5)}, active: map[uuid.UUID]int{}, } // 第 3 次起返回配额错误:前 2 个成功,第 3 个软失败 → 停止本 project。 starter := &fakeSessionStarter{quotaFrom: 3} notify := &fakeSchedNotify{} sched := NewScheduler(SchedulerDeps{ Projects: src, Sessions: starter, Notify: notify, MaxFanoutPerTick: 5, }) n, err := sched.Tick(context.Background()) require.NoError(t, err, "配额超限是软失败,不应返回 error") require.Equal(t, 2, n) require.Len(t, starter.started, 2) require.Equal(t, 1, notify.quota) } func TestScheduler_OtherErrorSkipsIssueNotProject(t *testing.T) { projID := uuid.New() src := &fakeReadySource{ projects: []uuid.UUID{projID}, ready: map[uuid.UUID][]*project.Issue{projID: mkReadyIssues(projID, 3)}, active: map[uuid.UUID]int{}, } starter := &fakeSessionStarter{failAll: true} sched := NewScheduler(SchedulerDeps{ Projects: src, Sessions: starter, MaxFanoutPerTick: 5, }) n, err := sched.Tick(context.Background()) require.NoError(t, err) require.Equal(t, 0, n, "全部 start 失败但非配额错误:跳过 issue,整轮不报错") } func TestScheduler_MaxConcurrentPerProjectPreCheck(t *testing.T) { projID := uuid.New() src := &fakeReadySource{ projects: []uuid.UUID{projID}, ready: map[uuid.UUID][]*project.Issue{projID: mkReadyIssues(projID, 10)}, active: map[uuid.UUID]int{projID: 3}, // 已有 3 个活跃 } starter := &fakeSessionStarter{} sched := NewScheduler(SchedulerDeps{ Projects: src, Sessions: starter, MaxFanoutPerTick: 10, MaxConcurrentPerProject: 4, // budget = 4-3 = 1 }) n, err := sched.Tick(context.Background()) require.NoError(t, err) require.Equal(t, 1, n, "软并发预检:budget=1,本 tick 只派 1 个") } func TestScheduler_AtConcurrencyCapDispatchesNothing(t *testing.T) { projID := uuid.New() src := &fakeReadySource{ projects: []uuid.UUID{projID}, ready: map[uuid.UUID][]*project.Issue{projID: mkReadyIssues(projID, 5)}, active: map[uuid.UUID]int{projID: 4}, } starter := &fakeSessionStarter{} sched := NewScheduler(SchedulerDeps{ Projects: src, Sessions: starter, MaxFanoutPerTick: 10, MaxConcurrentPerProject: 4, }) n, err := sched.Tick(context.Background()) require.NoError(t, err) require.Equal(t, 0, n) require.Empty(t, starter.started) } func TestScheduler_IdempotentAcrossTicks(t *testing.T) { projID := uuid.New() issues := mkReadyIssues(projID, 2) src := &fakeReadySource{ projects: []uuid.UUID{projID}, ready: map[uuid.UUID][]*project.Issue{projID: issues}, active: map[uuid.UUID]int{}, } starter := &fakeSessionStarter{} sched := NewScheduler(SchedulerDeps{Projects: src, Sessions: starter, MaxFanoutPerTick: 5}) n1, err := sched.Tick(context.Background()) require.NoError(t, err) require.Equal(t, 2, n1) // 第二 tick:模拟 DB 已排除被启动的 issue(清空 ready),不应重复派发。 src.mu.Lock() src.ready[projID] = nil src.mu.Unlock() n2, err := sched.Tick(context.Background()) require.NoError(t, err) require.Equal(t, 0, n2) require.Len(t, starter.started, 2, "跨 tick 不重复派发") }