Files
2026-06-22 08:55:57 +08:00

210 lines
6.4 KiB
Go

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 不重复派发")
}